From 18673ef019952fc031e78e2fba82223bfbe13634 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 18 Jun 2026 08:05:04 +0100 Subject: [PATCH 01/22] 466: update command helper to support multiple package arguments --- src/Command/BuildCommand.php | 12 +++--- src/Command/CommandHelper.php | 49 +++++++++++++++---------- src/Command/DownloadCommand.php | 8 ++-- src/Command/InfoCommand.php | 6 +-- src/Command/InstallCommand.php | 12 +++--- test/unit/Command/CommandHelperTest.php | 35 ++++++++++++++++-- 6 files changed, 79 insertions(+), 43 deletions(-) diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index 062eac76..519fd8b1 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -56,7 +56,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); try { - $requestedNameAndVersion = CommandHelper::requestedNameAndVersionPair($input); + $requestedNamesAndVersions = CommandHelper::requestedNameAndVersionPairs($input); } catch (InvalidPackageName $invalidPackageName) { return CommandHelper::handlePackageNotFound( $invalidPackageName, @@ -84,7 +84,7 @@ public function execute(InputInterface $input, OutputInterface $output): int new PieComposerRequest( $this->io, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, PieOperation::Resolve, [], // Configure options are not needed for resolve only false, // setting up INI not needed for build @@ -96,7 +96,7 @@ public function execute(InputInterface $input, OutputInterface $output): int ($this->prescanSystemDependencies)( $composer, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, CommandHelper::autoInstallSystemDependencies($input), ); } catch (Throwable $anything) { @@ -111,7 +111,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $package = ($this->dependencyResolver)( $composer, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, $forceInstallPackageVersion, ); } catch (UnableToResolveRequirement $unableToResolveRequirement) { @@ -141,7 +141,7 @@ public function execute(InputInterface $input, OutputInterface $output): int new PieComposerRequest( $this->io, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, PieOperation::Build, $configureOptionsValues, false, // setting up INI not needed for build @@ -153,7 +153,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $package, $composer, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, $forceInstallPackageVersion, false, ); diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 3d0f0254..bbacb625 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -37,6 +37,7 @@ use function array_key_exists; use function array_map; +use function array_values; use function assert; use function count; use function explode; @@ -110,8 +111,8 @@ public static function configureDownloadBuildInstallOptions(Command $command, bo if ($withRequestedPackageAndVersion) { $command->addArgument( self::ARG_REQUESTED_PACKAGE_AND_VERSION, - InputArgument::OPTIONAL, - 'The PIE package name and version constraint to use, in the format {vendor/package}{?:{?version-constraint}{?@stability}}, for example `xdebug/xdebug:^3.4@alpha`, `xdebug/xdebug:@alpha`, `xdebug/xdebug:^3.4`, etc.', + InputArgument::OPTIONAL | InputArgument::IS_ARRAY, + 'The PIE package names and versions constraint to use, in the format {vendor/package}{?:{?version-constraint}{?@stability}}, for example `xdebug/xdebug:^3.4@alpha`, `xdebug/xdebug:@alpha`, `xdebug/xdebug:^3.4`, etc.', ); } @@ -312,33 +313,41 @@ public static function shouldCheckSystemDependencies(InputInterface $input): boo || ! $input->getOption(self::OPTION_SUPPRESS_SYSTEM_DEPENDENCIES_CHECK); } - public static function requestedNameAndVersionPair(InputInterface $input): RequestedPackageAndVersion + /** @return non-empty-list */ + public static function requestedNameAndVersionPairs(InputInterface $input): array { - $requestedPackageString = $input->getArgument(self::ARG_REQUESTED_PACKAGE_AND_VERSION); + $requestedPackageStrings = $input->getArgument(self::ARG_REQUESTED_PACKAGE_AND_VERSION); - if (! is_string($requestedPackageString) || $requestedPackageString === '') { + if (! is_array($requestedPackageStrings) || ! count($requestedPackageStrings)) { throw new InvalidArgumentException('No package was requested for installation'); } - $nameAndVersionPairs = (new VersionParser()) - ->parseNameVersionPairs([$requestedPackageString]); - $requestedNameAndVersionPair = reset($nameAndVersionPairs); + Assert::allStringNotEmpty($requestedPackageStrings); - if (! is_array($requestedNameAndVersionPair)) { - throw new InvalidArgumentException('Failed to parse the name/version pair'); - } + return array_values(array_map( + static function (string $requestedPackageString): RequestedPackageAndVersion { + $nameAndVersionPairs = (new VersionParser()) + ->parseNameVersionPairs([$requestedPackageString]); + $requestedNameAndVersionPair = reset($nameAndVersionPairs); - if (! array_key_exists('version', $requestedNameAndVersionPair)) { - $requestedNameAndVersionPair['version'] = null; - } + if (! is_array($requestedNameAndVersionPair)) { + throw new InvalidArgumentException('Failed to parse the name/version pair'); + } + + if (! array_key_exists('version', $requestedNameAndVersionPair)) { + $requestedNameAndVersionPair['version'] = null; + } - Assert::stringNotEmpty($requestedNameAndVersionPair['name']); - Assert::nullOrStringNotEmpty($requestedNameAndVersionPair['version']); + Assert::stringNotEmpty($requestedNameAndVersionPair['name']); + Assert::nullOrStringNotEmpty($requestedNameAndVersionPair['version']); - return new RequestedPackageAndVersion( - $requestedNameAndVersionPair['name'], - $requestedNameAndVersionPair['version'], - ); + return new RequestedPackageAndVersion( + $requestedNameAndVersionPair['name'], + $requestedNameAndVersionPair['version'], + ); + }, + $requestedPackageStrings, + )); } public static function bindConfigureOptionsFromPackage(Command $command, Package $package, InputInterface $input): void diff --git a/src/Command/DownloadCommand.php b/src/Command/DownloadCommand.php index 34ce6554..fd8d7033 100644 --- a/src/Command/DownloadCommand.php +++ b/src/Command/DownloadCommand.php @@ -52,7 +52,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); try { - $requestedNameAndVersion = CommandHelper::requestedNameAndVersionPair($input); + $requestedNamesAndVersions = CommandHelper::requestedNameAndVersionPairs($input); } catch (InvalidPackageName $invalidPackageName) { return CommandHelper::handlePackageNotFound( $invalidPackageName, @@ -71,7 +71,7 @@ public function execute(InputInterface $input, OutputInterface $output): int new PieComposerRequest( $this->io, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, PieOperation::Download, [], // Configure options are not needed for download only false, // setting up INI not needed for download @@ -82,7 +82,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $package = ($this->dependencyResolver)( $composer, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, $forceInstallPackageVersion, ); } catch (UnableToResolveRequirement $unableToResolveRequirement) { @@ -107,7 +107,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $package, $composer, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, $forceInstallPackageVersion, false, ); diff --git a/src/Command/InfoCommand.php b/src/Command/InfoCommand.php index 21112a61..57f24c7a 100644 --- a/src/Command/InfoCommand.php +++ b/src/Command/InfoCommand.php @@ -56,7 +56,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); try { - $requestedNameAndVersion = CommandHelper::requestedNameAndVersionPair($input); + $requestedNamesAndVersions = CommandHelper::requestedNameAndVersionPairs($input); } catch (InvalidPackageName $invalidPackageName) { return CommandHelper::handlePackageNotFound( $invalidPackageName, @@ -74,7 +74,7 @@ public function execute(InputInterface $input, OutputInterface $output): int new PieComposerRequest( $this->io, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, PieOperation::Resolve, [], // Configure options are not needed for resolve only false, // setting up INI not needed for info @@ -85,7 +85,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $package = ($this->dependencyResolver)( $composer, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, true, ); } catch (UnableToResolveRequirement $unableToResolveRequirement) { diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index c74c8f7d..1843322f 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -70,7 +70,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); try { - $requestedNameAndVersion = CommandHelper::requestedNameAndVersionPair($input); + $requestedNamesAndVersions = CommandHelper::requestedNameAndVersionPairs($input); } catch (InvalidPackageName $invalidPackageName) { return CommandHelper::handlePackageNotFound( $invalidPackageName, @@ -98,7 +98,7 @@ public function execute(InputInterface $input, OutputInterface $output): int new PieComposerRequest( $this->io, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, PieOperation::Resolve, [], // Configure options are not needed for resolve only false, // setting up INI not needed for resolve step @@ -110,7 +110,7 @@ public function execute(InputInterface $input, OutputInterface $output): int ($this->prescanSystemDependencies)( $composer, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, CommandHelper::autoInstallSystemDependencies($input), ); } catch (Throwable $anything) { @@ -125,7 +125,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $package = ($this->dependencyResolver)( $composer, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, $forceInstallPackageVersion, ); } catch (UnableToResolveRequirement $unableToResolveRequirement) { @@ -155,7 +155,7 @@ public function execute(InputInterface $input, OutputInterface $output): int new PieComposerRequest( $this->io, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, PieOperation::Install, $configureOptionsValues, CommandHelper::determineAttemptToSetupIniFile($input), @@ -167,7 +167,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $package, $composer, $targetPlatform, - $requestedNameAndVersion, + $requestedNamesAndVersions, $forceInstallPackageVersion, true, ); diff --git a/test/unit/Command/CommandHelperTest.php b/test/unit/Command/CommandHelperTest.php index 04c1f1ee..f7df3794 100644 --- a/test/unit/Command/CommandHelperTest.php +++ b/test/unit/Command/CommandHelperTest.php @@ -66,11 +66,38 @@ public function testRequestedNameAndVersionPair(string $requestedPackageAndVersi $input->expects(self::once()) ->method('getArgument') ->with('requested-package-and-version') - ->willReturn($requestedPackageAndVersion); + ->willReturn([$requestedPackageAndVersion]); self::assertEquals( - new RequestedPackageAndVersion($expectedPackage, $expectedVersion), - CommandHelper::requestedNameAndVersionPair($input), + [new RequestedPackageAndVersion($expectedPackage, $expectedVersion)], + CommandHelper::requestedNameAndVersionPairs($input), + ); + } + + public function testRequestedNameAndVersionPairSupportsMultiple(): void + { + $input = $this->createMock(InputInterface::class); + + $input->expects(self::once()) + ->method('getArgument') + ->with('requested-package-and-version') + ->willReturn([ + 'a/ext', + 'b/ext:^1.2', + 'c/ext:*', + 'd/ext:@alpha', + 'e/ext:1.2.3', + ]); + + self::assertEquals( + [ + new RequestedPackageAndVersion('a/ext', null), + new RequestedPackageAndVersion('b/ext', '^1.2'), + new RequestedPackageAndVersion('c/ext', '*'), + new RequestedPackageAndVersion('d/ext', '@alpha'), + new RequestedPackageAndVersion('e/ext', '1.2.3'), + ], + CommandHelper::requestedNameAndVersionPairs($input), ); } @@ -85,7 +112,7 @@ public function testInvalidRequestedNameAndVersionPairThrowsExceptionWhenNoPacka $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('No package was requested for installation'); - CommandHelper::requestedNameAndVersionPair($input); + CommandHelper::requestedNameAndVersionPairs($input); } public function testBindingConfigurationOptionsFromPackage(): void From 24aec6352e37c68786187f0b17c750df9c57a696 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 18 Jun 2026 08:06:34 +0100 Subject: [PATCH 02/22] 466: update PieComposerRequest to have a list of packages --- src/ComposerIntegration/PieComposerRequest.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ComposerIntegration/PieComposerRequest.php b/src/ComposerIntegration/PieComposerRequest.php index 3b96902d..8964263b 100644 --- a/src/ComposerIntegration/PieComposerRequest.php +++ b/src/ComposerIntegration/PieComposerRequest.php @@ -15,11 +15,14 @@ */ final class PieComposerRequest { - /** @param list $configureOptions */ + /** + * @param list $requestedPackages + * @param list $configureOptions + */ public function __construct( public readonly IOInterface $pieOutput, public readonly TargetPlatform $targetPlatform, - public readonly RequestedPackageAndVersion $requestedPackage, + public readonly array $requestedPackages, public readonly PieOperation $operation, public readonly array $configureOptions, public readonly bool $attemptToSetupIniFile, @@ -37,7 +40,7 @@ public static function noOperation( return new PieComposerRequest( $pieOutput, $targetPlatform, - new RequestedPackageAndVersion('null/null', null), + [], PieOperation::Resolve, [], false, From 5dc5a512641ec3968df543990f3f39d439502bc5 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 18 Jun 2026 08:59:20 +0100 Subject: [PATCH 03/22] 466: extract currentContent to public method for PieJsonEditor --- src/ComposerIntegration/PieJsonEditor.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/ComposerIntegration/PieJsonEditor.php b/src/ComposerIntegration/PieJsonEditor.php index 3a84f71c..8ba54ce7 100644 --- a/src/ComposerIntegration/PieJsonEditor.php +++ b/src/ComposerIntegration/PieJsonEditor.php @@ -68,6 +68,11 @@ public function ensureExists(): self return $this; } + public function currentContent(): string + { + return file_get_contents($this->pieJsonFilename); + } + /** * Add a package to the `require` section of the given `pie.json`. Returns * the original `pie.json` content, in case it needs to be restored later. @@ -77,7 +82,7 @@ public function ensureExists(): self */ public function addRequire(string $package, string $version): string { - $originalPieJsonContent = file_get_contents($this->pieJsonFilename); + $originalPieJsonContent = $this->currentContent(); (new JsonConfigSource( new JsonFile( @@ -97,7 +102,7 @@ public function addRequire(string $package, string $version): string */ public function removeRequire(string $package): string { - $originalPieJsonContent = file_get_contents($this->pieJsonFilename); + $originalPieJsonContent = $this->currentContent(); (new JsonConfigSource( new JsonFile( @@ -115,7 +120,7 @@ public function revert(string $originalPieJsonContent): void public function excludePackagistOrg(): string { - $originalPieJsonContent = file_get_contents($this->pieJsonFilename); + $originalPieJsonContent = $this->currentContent(); (new JsonConfigSource( new JsonFile( @@ -138,7 +143,7 @@ public function addRepository( string $type, string $url, ): string { - $originalPieJsonContent = file_get_contents($this->pieJsonFilename); + $originalPieJsonContent = $this->currentContent(); (new JsonConfigSource( new JsonFile( @@ -162,7 +167,7 @@ public function addRepository( public function removeRepository( string $name, ): string { - $originalPieJsonContent = file_get_contents($this->pieJsonFilename); + $originalPieJsonContent = $this->currentContent(); (new JsonConfigSource( new JsonFile( From 0baf9abd643659c6c3fab1e5f05a128fcf51e587 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 18 Jun 2026 11:15:24 +0100 Subject: [PATCH 04/22] 466: update dependency resolution for info/download/build/install to use a loop for each packages --- src/Command/BuildCommand.php | 45 ++++---- src/Command/CommandHelper.php | 40 +++++++ src/Command/DownloadCommand.php | 8 +- src/Command/InfoCommand.php | 6 +- src/Command/InstallCommand.php | 45 ++++---- src/DependencyResolver/DependencyResolver.php | 2 +- .../ResolveDependencyWithComposer.php | 4 +- .../ResolvedPackageRequest.php | 15 +++ test/unit/Command/CommandHelperTest.php | 105 ++++++++++++++++++ 9 files changed, 214 insertions(+), 56 deletions(-) create mode 100644 src/DependencyResolver/ResolvedPackageRequest.php diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index 519fd8b1..1efaa0b2 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -23,9 +23,6 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Throwable; - -use function sprintf; #[AsCommand( name: 'build', @@ -91,24 +88,27 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); - if (CommandHelper::shouldCheckSystemDependencies($input)) { - try { - ($this->prescanSystemDependencies)( - $composer, - $targetPlatform, - $requestedNamesAndVersions, - CommandHelper::autoInstallSystemDependencies($input), - ); - } catch (Throwable $anything) { - $this->io->writeError( - 'Skipping system dependency pre-scan due to exception: ' . $anything->getMessage(), - verbosity: IOInterface::VERBOSE, - ); - } - } + // @todo fix this +// if (CommandHelper::shouldCheckSystemDependencies($input)) { +// try { +// ($this->prescanSystemDependencies)( +// $composer, +// $targetPlatform, +// $requestedNamesAndVersions, +// CommandHelper::autoInstallSystemDependencies($input), +// ); +// } catch (Throwable $anything) { +// $this->io->writeError( +// 'Skipping system dependency pre-scan due to exception: ' . $anything->getMessage(), +// verbosity: IOInterface::VERBOSE, +// ); +// } +// } try { - $package = ($this->dependencyResolver)( + $resolvedPackages = CommandHelper::resolveRequestedPackages( + $this->dependencyResolver, + $this->io, $composer, $targetPlatform, $requestedNamesAndVersions, @@ -129,12 +129,11 @@ public function execute(InputInterface $input, OutputInterface $output): int return self::INVALID; } - $this->io->write(sprintf('Found package: %s which provides %s', $package->prettyNameAndVersion(), $package->extensionName()->nameWithExtPrefix())); - // Now we know what package we have, we can validate the configure options for the command and re-create the // Composer instance with the populated configure options - CommandHelper::bindConfigureOptionsFromPackage($this, $package, $input); - $configureOptionsValues = CommandHelper::processConfigureOptionsFromInput($package, $input); + // @todo handle this; CommandHelper::bindConfigureOptionsFromPackage($this, $package, $input); + // @todo handle this; $configureOptionsValues = CommandHelper::processConfigureOptionsFromInput($package, $input); + $configureOptionsValues = []; // @todo handle this $composer = PieComposerFactory::createPieComposer( $this->container, diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index bbacb625..c46292c0 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -16,9 +16,12 @@ use OutOfRangeException; use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; +use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; +use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\Package; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; +use Php\Pie\DependencyResolver\ResolvedPackageRequest; use Php\Pie\DependencyResolver\UnableToResolveRequirement; use Php\Pie\ExtensionName; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; @@ -350,6 +353,43 @@ static function (string $requestedPackageString): RequestedPackageAndVersion { )); } + /** + * @param non-empty-list $requestedNamesAndVersions + * + * @return non-empty-list + * + * @throws UnableToResolveRequirement + * @throws BundledPhpExtensionRefusal + */ + public static function resolveRequestedPackages( + DependencyResolver $dependencyResolver, + IOInterface $io, + Composer $composer, + TargetPlatform $targetPlatform, + array $requestedNamesAndVersions, + bool $forceInstallPackageVersion, + ): array { + return array_map( + static function (RequestedPackageAndVersion $requestedNameAndVersion) use ($dependencyResolver, $io, $composer, $targetPlatform, $forceInstallPackageVersion): ResolvedPackageRequest { + $resolvedPackage = $dependencyResolver( + $composer, + $targetPlatform, + $requestedNameAndVersion, + $forceInstallPackageVersion, + ); + + $io->write(sprintf( + 'Found package: %s which provides %s', + $resolvedPackage->piePackage->prettyNameAndVersion(), + $resolvedPackage->piePackage->extensionName()->nameWithExtPrefix(), + )); + + return $resolvedPackage; + }, + $requestedNamesAndVersions, + ); + } + public static function bindConfigureOptionsFromPackage(Command $command, Package $package, InputInterface $input): void { foreach ($package->configureOptions() as $configureOption) { diff --git a/src/Command/DownloadCommand.php b/src/Command/DownloadCommand.php index fd8d7033..e67a592d 100644 --- a/src/Command/DownloadCommand.php +++ b/src/Command/DownloadCommand.php @@ -21,8 +21,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use function sprintf; - #[AsCommand( name: 'download', description: 'Same behaviour as build, but puts the files in a local directory for manual building and installation.', @@ -79,7 +77,9 @@ public function execute(InputInterface $input, OutputInterface $output): int ); try { - $package = ($this->dependencyResolver)( + $resolvedPackages = CommandHelper::resolveRequestedPackages( + $this->dependencyResolver, + $this->io, $composer, $targetPlatform, $requestedNamesAndVersions, @@ -100,8 +100,6 @@ public function execute(InputInterface $input, OutputInterface $output): int return self::INVALID; } - $this->io->write(sprintf('Found package: %s which provides %s', $package->prettyNameAndVersion(), $package->extensionName()->nameWithExtPrefix())); - try { $this->composerIntegrationHandler->runInstall( $package, diff --git a/src/Command/InfoCommand.php b/src/Command/InfoCommand.php index 57f24c7a..28d05a5b 100644 --- a/src/Command/InfoCommand.php +++ b/src/Command/InfoCommand.php @@ -82,7 +82,9 @@ public function execute(InputInterface $input, OutputInterface $output): int ); try { - $package = ($this->dependencyResolver)( + $resolvedPackages = CommandHelper::resolveRequestedPackages( + $this->dependencyResolver, + $this->io, $composer, $targetPlatform, $requestedNamesAndVersions, @@ -103,7 +105,7 @@ public function execute(InputInterface $input, OutputInterface $output): int return self::INVALID; } - $this->io->write(sprintf('Found package: %s which provides %s', $package->prettyNameAndVersion(), $package->extensionName()->nameWithExtPrefix())); + $package = $resolvedPackages[0]->piePackage; $this->io->write(sprintf('Extension name: %s', $package->extensionName()->name())); $this->io->write(sprintf('Extension type: %s (%s)', $package->extensionType()->value, $package->extensionType()->name)); diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 1843322f..f322274e 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -24,9 +24,6 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Throwable; - -use function sprintf; #[AsCommand( name: 'install', @@ -105,24 +102,27 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); - if (CommandHelper::shouldCheckSystemDependencies($input)) { - try { - ($this->prescanSystemDependencies)( - $composer, - $targetPlatform, - $requestedNamesAndVersions, - CommandHelper::autoInstallSystemDependencies($input), - ); - } catch (Throwable $anything) { - $this->io->writeError( - 'Skipping system dependency pre-scan due to exception: ' . $anything->getMessage(), - verbosity: IOInterface::VERBOSE, - ); - } - } + // @todo fix this +// if (CommandHelper::shouldCheckSystemDependencies($input)) { +// try { +// ($this->prescanSystemDependencies)( +// $composer, +// $targetPlatform, +// $requestedNamesAndVersions, +// CommandHelper::autoInstallSystemDependencies($input), +// ); +// } catch (Throwable $anything) { +// $this->io->writeError( +// 'Skipping system dependency pre-scan due to exception: ' . $anything->getMessage(), +// verbosity: IOInterface::VERBOSE, +// ); +// } +// } try { - $package = ($this->dependencyResolver)( + $resolvedPackages = CommandHelper::resolveRequestedPackages( + $this->dependencyResolver, + $this->io, $composer, $targetPlatform, $requestedNamesAndVersions, @@ -143,12 +143,11 @@ public function execute(InputInterface $input, OutputInterface $output): int return self::INVALID; } - $this->io->write(sprintf('Found package: %s which provides %s', $package->prettyNameAndVersion(), $package->extensionName()->nameWithExtPrefix())); - // Now we know what package we have, we can validate the configure options for the command and re-create the // Composer instance with the populated configure options - CommandHelper::bindConfigureOptionsFromPackage($this, $package, $input); - $configureOptionsValues = CommandHelper::processConfigureOptionsFromInput($package, $input); + // @todo handle this; CommandHelper::bindConfigureOptionsFromPackage($this, $package, $input); + // @todo handle this; $configureOptionsValues = CommandHelper::processConfigureOptionsFromInput($package, $input); + $configureOptionsValues = []; // @todo handle this $composer = PieComposerFactory::createPieComposer( $this->container, diff --git a/src/DependencyResolver/DependencyResolver.php b/src/DependencyResolver/DependencyResolver.php index 8179089f..5a6e06d7 100644 --- a/src/DependencyResolver/DependencyResolver.php +++ b/src/DependencyResolver/DependencyResolver.php @@ -16,5 +16,5 @@ public function __invoke( TargetPlatform $targetPlatform, RequestedPackageAndVersion $requestedPackageAndVersion, bool $forceInstallPackageVersion, - ): Package; + ): ResolvedPackageRequest; } diff --git a/src/DependencyResolver/ResolveDependencyWithComposer.php b/src/DependencyResolver/ResolveDependencyWithComposer.php index d377fe49..059ca117 100644 --- a/src/DependencyResolver/ResolveDependencyWithComposer.php +++ b/src/DependencyResolver/ResolveDependencyWithComposer.php @@ -33,7 +33,7 @@ public function __invoke( TargetPlatform $targetPlatform, RequestedPackageAndVersion $requestedPackageAndVersion, bool $forceInstallPackageVersion, - ): Package { + ): ResolvedPackageRequest { $versionSelector = VersionSelectorFactory::make($composer, $requestedPackageAndVersion, $targetPlatform); $package = $versionSelector->findBestCandidate( @@ -73,7 +73,7 @@ public function __invoke( $this->assertCompatibleThreadSafetyMode($targetPlatform->threadSafety, $piePackage); } - return $piePackage; + return new ResolvedPackageRequest($piePackage, $requestedPackageAndVersion); } private function assertCompatibleThreadSafetyMode(ThreadSafetyMode $threadSafetyMode, Package $resolvedPackage): void diff --git a/src/DependencyResolver/ResolvedPackageRequest.php b/src/DependencyResolver/ResolvedPackageRequest.php new file mode 100644 index 00000000..41107ecf --- /dev/null +++ b/src/DependencyResolver/ResolvedPackageRequest.php @@ -0,0 +1,15 @@ +method('getPrettyName')->willReturn($prettyName); + $composerPackage->method('getPrettyVersion')->willReturn($prettyVersion); + $composerPackage->method('getType')->willReturn('php-ext'); + + return Package::fromComposerCompletePackage($composerPackage); + } + + public function testResolveRequestedPackagesResolvesEachRequestedPackageAndWritesOutput(): void + { + $requestedA = new RequestedPackageAndVersion('foo/bar', null); + $requestedB = new RequestedPackageAndVersion('baz/qux', '^1.0'); + + $resolvedA = new ResolvedPackageRequest(self::packageNamed('foo/bar', '1.0.0'), $requestedA); + $resolvedB = new ResolvedPackageRequest(self::packageNamed('baz/qux', '2.0.0'), $requestedB); + + $composer = $this->createMock(Composer::class); + $targetPlatform = $this->createMock(TargetPlatform::class); + + $dependencyResolver = $this->createMock(DependencyResolver::class); + $dependencyResolver->expects(self::exactly(2)) + ->method('__invoke') + ->willReturnCallback( + static function (Composer $givenComposer, TargetPlatform $givenTargetPlatform, RequestedPackageAndVersion $requested, bool $force) use ($composer, $targetPlatform, $requestedA, $requestedB, $resolvedA, $resolvedB): ResolvedPackageRequest { + self::assertSame($composer, $givenComposer); + self::assertSame($targetPlatform, $givenTargetPlatform); + self::assertTrue($force); + + if ($requested === $requestedA) { + return $resolvedA; + } + + self::assertSame($requestedB, $requested); + + return $resolvedB; + }, + ); + + $io = new BufferIO(); + + $resolvedPackages = CommandHelper::resolveRequestedPackages( + $dependencyResolver, + $io, + $composer, + $targetPlatform, + [$requestedA, $requestedB], + true, + ); + + self::assertSame([$resolvedA, $resolvedB], $resolvedPackages); + self::assertSame( + "Found package: foo/bar:1.0.0 which provides ext-bar\nFound package: baz/qux:2.0.0 which provides ext-qux", + str_replace("\r\n", "\n", trim($io->getOutput())), + ); + } + + public function testResolveRequestedPackagesPropagatesUnableToResolveRequirement(): void + { + $requested = new RequestedPackageAndVersion('foo/bar', null); + + $exception = new UnableToResolveRequirement('Could not resolve foo/bar', $requested); + + $dependencyResolver = $this->createMock(DependencyResolver::class); + $dependencyResolver->method('__invoke')->willThrowException($exception); + + $this->expectExceptionObject($exception); + + CommandHelper::resolveRequestedPackages( + $dependencyResolver, + new NullIO(), + $this->createMock(Composer::class), + $this->createMock(TargetPlatform::class), + [$requested], + false, + ); + } + + public function testResolveRequestedPackagesPropagatesBundledPhpExtensionRefusal(): void + { + $requested = new RequestedPackageAndVersion('foo/bar', null); + + $exception = BundledPhpExtensionRefusal::forPackage(self::packageNamed('foo/bar', '1.0.0')); + + $dependencyResolver = $this->createMock(DependencyResolver::class); + $dependencyResolver->method('__invoke')->willThrowException($exception); + + $this->expectExceptionObject($exception); + + CommandHelper::resolveRequestedPackages( + $dependencyResolver, + new NullIO(), + $this->createMock(Composer::class), + $this->createMock(TargetPlatform::class), + [$requested], + false, + ); + } + public function testProcessingConfigureOptionsFromInput(): void { $composerPackage = $this->createMock(CompletePackageInterface::class); From d7f981f69009c65aec162b24b8854f212a02da21 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 18 Jun 2026 11:59:56 +0100 Subject: [PATCH 05/22] 466: Updated \Php\Pie\ComposerIntegration\ComposerIntegrationHandler::runInstall to take a list of resolved packages --- src/Command/BuildCommand.php | 3 +- src/Command/DownloadCommand.php | 3 +- src/Command/InstallCommand.php | 3 +- .../ComposerIntegrationHandler.php | 57 ++++++++++++++----- .../PhpBinaryPathBasedPlatformRepository.php | 8 ++- .../PieComposerInstaller.php | 21 ++++--- .../VersionSelectorFactory.php | 2 +- .../FetchDependencyStatuses.php | 2 +- 8 files changed, 65 insertions(+), 34 deletions(-) diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index 1efaa0b2..deb53dfc 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -149,10 +149,9 @@ public function execute(InputInterface $input, OutputInterface $output): int try { $this->composerIntegrationHandler->runInstall( - $package, + $resolvedPackages, $composer, $targetPlatform, - $requestedNamesAndVersions, $forceInstallPackageVersion, false, ); diff --git a/src/Command/DownloadCommand.php b/src/Command/DownloadCommand.php index e67a592d..b8781ed8 100644 --- a/src/Command/DownloadCommand.php +++ b/src/Command/DownloadCommand.php @@ -102,10 +102,9 @@ public function execute(InputInterface $input, OutputInterface $output): int try { $this->composerIntegrationHandler->runInstall( - $package, + $resolvedPackages, $composer, $targetPlatform, - $requestedNamesAndVersions, $forceInstallPackageVersion, false, ); diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index f322274e..3c37f1b9 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -163,10 +163,9 @@ public function execute(InputInterface $input, OutputInterface $output): int try { $this->composerIntegrationHandler->runInstall( - $package, + $resolvedPackages, $composer, $targetPlatform, - $requestedNamesAndVersions, $forceInstallPackageVersion, true, ); diff --git a/src/ComposerIntegration/ComposerIntegrationHandler.php b/src/ComposerIntegration/ComposerIntegrationHandler.php index 6557487f..7069a20e 100644 --- a/src/ComposerIntegration/ComposerIntegrationHandler.php +++ b/src/ComposerIntegration/ComposerIntegrationHandler.php @@ -12,12 +12,14 @@ use Composer\Package\CompletePackageInterface; use Php\Pie\DependencyResolver\Package; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; +use Php\Pie\DependencyResolver\ResolvedPackageRequest; use Php\Pie\ExtensionName; use Php\Pie\Platform; use Php\Pie\Platform\TargetPlatform; use Php\Pie\Util\Emoji; use Psr\Container\ContainerInterface; +use function array_map; use function assert; use function file_exists; use function sprintf; @@ -32,25 +34,23 @@ public function __construct( ) { } - public function runInstall( - Package $package, + private function addPackageIntoPieJson( + ResolvedPackageRequest $resolvedPackageRequest, Composer $composer, TargetPlatform $targetPlatform, - RequestedPackageAndVersion $requestedPackageAndVersion, - bool $forceInstallPackageVersion, - bool $runCleanup, + PieJsonEditor $pieJsonEditor, ): void { - $versionSelector = VersionSelectorFactory::make($composer, $requestedPackageAndVersion, $targetPlatform); + $versionSelector = VersionSelectorFactory::make($composer, $resolvedPackageRequest->requestedPackageAndVersion, $targetPlatform); - $recommendedRequireVersion = $requestedPackageAndVersion->version; + $recommendedRequireVersion = $resolvedPackageRequest->requestedPackageAndVersion->version; // If user did not request a specific require version, use Composer to recommend one for the pie.json if ($recommendedRequireVersion === null) { - $recommendedRequireVersion = $versionSelector->findRecommendedRequireVersion($package->composerPackage()); + $recommendedRequireVersion = $versionSelector->findRecommendedRequireVersion($resolvedPackageRequest->piePackage->composerPackage()); } - if ($package->isBundledPhpExtension()) { - $stability = $package->composerPackage()->getStability(); + if ($resolvedPackageRequest->piePackage->isBundledPhpExtension()) { + $stability = $resolvedPackageRequest->piePackage->composerPackage()->getStability(); $stabilitySuffix = ''; if ($stability !== 'stable') { $stabilitySuffix = '@' . $stability; @@ -60,11 +60,32 @@ public function runInstall( } // Write the new requirement to pie.json; because we later essentially just do a `composer install` using that file + $pieJsonEditor->addRequire( + $resolvedPackageRequest->requestedPackageAndVersion->package, + $recommendedRequireVersion !== '' ? $recommendedRequireVersion : '*', + ); + } + + /** @param list $resolvedRequestedPackages */ + public function runInstall( + array $resolvedRequestedPackages, + Composer $composer, + TargetPlatform $targetPlatform, + bool $forceInstallPackageVersion, + bool $runCleanup, + ): void { $pieComposerJson = Platform::getPieJsonFilename($targetPlatform); $pieJsonEditor = PieJsonEditor::fromTargetPlatform($targetPlatform); - $originalPieJsonContent = $pieJsonEditor->addRequire( - $requestedPackageAndVersion->package, - $recommendedRequireVersion !== '' ? $recommendedRequireVersion : '*', + $originalPieJsonContent = $pieJsonEditor->currentContent(); + + array_map( + fn (ResolvedPackageRequest $resolvedPackageRequest) => $this->addPackageIntoPieJson( + $resolvedPackageRequest, + $composer, + $targetPlatform, + $pieJsonEditor, + ), + $resolvedRequestedPackages, ); // Refresh the Composer instance so it re-reads the updated pie.json @@ -129,7 +150,10 @@ public function runInstall( $composerInstaller = PieComposerInstaller::createWithPhpBinary( $targetPlatform->phpBinaryPath, - $package->extensionName(), + array_map( + static fn (ResolvedPackageRequest $resolvedPackageRequest) => $resolvedPackageRequest->piePackage->extensionName(), + $resolvedRequestedPackages, + ), $this->arrayCollectionIo, $composer, ); @@ -143,7 +167,10 @@ public function runInstall( if (file_exists(PieComposerFactory::getLockFile($pieComposerJson))) { $composerInstaller->setUpdate(true); - $composerInstaller->setUpdateAllowList([$requestedPackageAndVersion->package]); + $composerInstaller->setUpdateAllowList(array_map( + static fn (ResolvedPackageRequest $resolvedPackageRequest) => $resolvedPackageRequest->requestedPackageAndVersion->package, + $resolvedRequestedPackages, + )); } $resultCode = $composerInstaller->run(); diff --git a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php index 1adabc02..8729a807 100644 --- a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php +++ b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php @@ -17,6 +17,7 @@ use Symfony\Component\Process\Exception\ProcessFailedException; use UnexpectedValueException; +use function array_map; use function explode; use function in_array; use function str_replace; @@ -30,7 +31,8 @@ class PhpBinaryPathBasedPlatformRepository extends PlatformRepository { private VersionParser $versionParser; - public function __construct(PhpBinaryPath $phpBinaryPath, Composer $composer, InstalledPiePackages $installedPiePackages, ExtensionName|null $extensionBeingInstalled) + /** @param list $extensionsBeingInstalled */ + public function __construct(PhpBinaryPath $phpBinaryPath, Composer $composer, InstalledPiePackages $installedPiePackages, array $extensionsBeingInstalled) { $this->versionParser = new VersionParser(); $this->packages = []; @@ -58,6 +60,8 @@ public function __construct(PhpBinaryPath $phpBinaryPath, Composer $composer, In } } + $extensionNamesBeingInstalled = array_map(static fn (ExtensionName $ext) => $ext->name(), $extensionsBeingInstalled); + foreach ($extVersions as $extension => $extensionVersion) { /** * If the extension we're trying to exclude is not excluded from this list if it is already installed @@ -65,7 +69,7 @@ public function __construct(PhpBinaryPath $phpBinaryPath, Composer $composer, In * * @link https://github.com/php/pie/issues/150 */ - if ($extensionBeingInstalled !== null && $extension === $extensionBeingInstalled->name()) { + if (in_array($extension, $extensionNamesBeingInstalled, true)) { continue; } diff --git a/src/ComposerIntegration/PieComposerInstaller.php b/src/ComposerIntegration/PieComposerInstaller.php index 423768be..24c2fe58 100644 --- a/src/ComposerIntegration/PieComposerInstaller.php +++ b/src/ComposerIntegration/PieComposerInstaller.php @@ -16,22 +16,25 @@ /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ class PieComposerInstaller extends Installer { - private PhpBinaryPath|null $phpBinaryPath = null; - private ExtensionName|null $extensionBeingInstalled = null; - private Composer|null $composer = null; + private PhpBinaryPath|null $phpBinaryPath = null; + /** @var list */ + private array $extensionsBeingInstalled = []; + private Composer|null $composer = null; protected function createPlatformRepo(bool $forUpdate): PlatformRepository { Assert::notNull($this->phpBinaryPath, '$phpBinaryPath was not set, maybe createWithPhpBinary was not used?'); - Assert::notNull($this->extensionBeingInstalled, '$extensionBeingInstalled was not set, maybe createWithPhpBinary was not used?'); + Assert::isNonEmptyList($this->extensionsBeingInstalled, '$extensionsBeingInstalled was not set, maybe createWithPhpBinary was not used?'); + Assert::allIsInstanceOf($this->extensionsBeingInstalled, ExtensionName::class, '$extensionsBeingInstalled were not all ExtensionName instances'); Assert::notNull($this->composer, '$composer was not set, maybe createWithPhpBinary was not used?'); - return new PhpBinaryPathBasedPlatformRepository($this->phpBinaryPath, $this->composer, new InstalledPiePackages(), $this->extensionBeingInstalled); + return new PhpBinaryPathBasedPlatformRepository($this->phpBinaryPath, $this->composer, new InstalledPiePackages(), $this->extensionsBeingInstalled); } + /** @param list $extensionsBeingInstalled */ public static function createWithPhpBinary( PhpBinaryPath $php, - ExtensionName $extensionBeingInstalled, + array $extensionsBeingInstalled, IOInterface $io, Composer $composer, ): self { @@ -47,9 +50,9 @@ public static function createWithPhpBinary( $composer->getAutoloadGenerator(), ); - $composerInstaller->phpBinaryPath = $php; - $composerInstaller->extensionBeingInstalled = $extensionBeingInstalled; - $composerInstaller->composer = $composer; + $composerInstaller->phpBinaryPath = $php; + $composerInstaller->extensionsBeingInstalled = $extensionsBeingInstalled; + $composerInstaller->composer = $composer; return $composerInstaller; } diff --git a/src/ComposerIntegration/VersionSelectorFactory.php b/src/ComposerIntegration/VersionSelectorFactory.php index 3baa2068..7079f59f 100644 --- a/src/ComposerIntegration/VersionSelectorFactory.php +++ b/src/ComposerIntegration/VersionSelectorFactory.php @@ -35,7 +35,7 @@ public static function make( ): VersionSelector { return new VersionSelector( self::factoryRepositorySet($composer, $requestedPackageAndVersion), - new PhpBinaryPathBasedPlatformRepository($targetPlatform->phpBinaryPath, $composer, new InstalledPiePackages(), null), + new PhpBinaryPathBasedPlatformRepository($targetPlatform->phpBinaryPath, $composer, new InstalledPiePackages(), []), ); } } diff --git a/src/DependencyResolver/FetchDependencyStatuses.php b/src/DependencyResolver/FetchDependencyStatuses.php index 4269173f..8ba213ab 100644 --- a/src/DependencyResolver/FetchDependencyStatuses.php +++ b/src/DependencyResolver/FetchDependencyStatuses.php @@ -28,7 +28,7 @@ public function __invoke(TargetPlatform $targetPlatform, Composer $composer, Com /** @var array $platformConstraints */ $platformConstraints = []; - $composerPlatform = new PhpBinaryPathBasedPlatformRepository($targetPlatform->phpBinaryPath, $composer, new InstalledPiePackages(), null); + $composerPlatform = new PhpBinaryPathBasedPlatformRepository($targetPlatform->phpBinaryPath, $composer, new InstalledPiePackages(), []); foreach ($composerPlatform->getPackages() as $platformPackage) { $platformConstraints[$platformPackage->getName()] = new Constraint('==', $platformPackage->getVersion()); } From d1352d6a6eedac588344aec5c1599bbcb7bb2538 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Fri, 19 Jun 2026 10:38:21 +0100 Subject: [PATCH 06/22] 466: updating feature descriptions to allow download/build/install/uninstall of multiple extensions --- features/build-extensions.feature | 5 + features/bundled-php-extensions.feature | 1 + features/download-extensions.feature | 5 + features/install-extensions.feature | 5 + features/uninstall-extensions.feature | 6 + test/behaviour/CliContext.php | 160 +++++++++++++++--------- 6 files changed, 125 insertions(+), 57 deletions(-) diff --git a/features/build-extensions.feature b/features/build-extensions.feature index 3006594f..109435e2 100644 --- a/features/build-extensions.feature +++ b/features/build-extensions.feature @@ -15,3 +15,8 @@ Feature: Extensions can be built with PIE Example: An extension can be built with configure options When I run a command to build an extension with configure options Then the extension should have been built with options + + # pie build + Example: Multiple extensions can be built at once + When I run a command to build multiple extensions + Then the extensions should have been built diff --git a/features/bundled-php-extensions.feature b/features/bundled-php-extensions.feature index 2a6f0d50..f598c678 100644 --- a/features/bundled-php-extensions.feature +++ b/features/bundled-php-extensions.feature @@ -6,6 +6,7 @@ Feature: Bundled PHP extensions can be installed When I install the sodium extension with PIE Then the extension should have been installed and enabled + # pie uninstall php/sodium Example: A bundled extension installed with PIE can be uninstalled Given I have the sodium extension installed with PIE When I run a command to uninstall an extension diff --git a/features/download-extensions.feature b/features/download-extensions.feature index 7f33956e..7d7e7a0b 100644 --- a/features/download-extensions.feature +++ b/features/download-extensions.feature @@ -20,3 +20,8 @@ Feature: Extensions can be downloaded with PIE Example: An in-development version can be downloaded on non-Windows systems When I run a command to download version "dev-main" of an extension Then version "dev-main" should have been downloaded + + # pie download + Example: Multiple extensions can be downloaded at once + When I run a command to download multiple extensions + Then the extensions should have been downloaded diff --git a/features/install-extensions.feature b/features/install-extensions.feature index 63d0b239..a2b6ebe6 100644 --- a/features/install-extensions.feature +++ b/features/install-extensions.feature @@ -9,3 +9,8 @@ Feature: Extensions can be installed with PIE Example: An extension can be installed and enabled When I run a command to install an extension Then the extension should have been installed and enabled + + # pie install + Example: Multiple extensions can be installed at once + When I run a command to install multiple extensions + Then the extensions should have been installed and enabled diff --git a/features/uninstall-extensions.feature b/features/uninstall-extensions.feature index 93002d4f..11c13cad 100644 --- a/features/uninstall-extensions.feature +++ b/features/uninstall-extensions.feature @@ -5,3 +5,9 @@ Feature: Extensions can be uninstalled with PIE Given an extension was previously installed and enabled When I run a command to uninstall an extension Then the extension should not be installed anymore + + # pie uninstall + Example: Multiple extensions can be uninstalled at once + Given multiple extensions were previously installed and enabled + When I run a command to uninstall multiple extensions + Then the extensions should not be installed anymore diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index fc4d51b7..84c1dba0 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -30,10 +30,9 @@ class CliContext implements Context private string|null $errorOutput = null; private int|null $exitCode = null; /** @var list */ - private array $phpArguments = []; - private string $theExtension = 'example_pie_extension'; - /** @var non-empty-string */ - private string $thePackage = 'asgrim/example-pie-extension'; + private array $phpArguments = []; + /** @var list */ + private array $interactions = []; private string|null $workingDirectory = null; /** @throws PcreException */ @@ -58,12 +57,22 @@ public function removeInstalledExtensions(): void #[Given('an extension was previously downloaded but not built')] public function iRunACommandToDownloadTheLatestVersionOfAnExtension(): void { + $this->interactions[] = ['extension' => 'example_pie_extension', 'package' => 'asgrim/example-pie-extension']; $this->runPieCommand(['download', 'asgrim/example-pie-extension']); } + #[When('I run a command to download multiple extensions')] + public function iRunACommandToDownloadMultipleExtensions(): void + { + $this->interactions[] = ['extension' => 'example_pie_extension', 'package' => 'asgrim/example-pie-extension']; + $this->interactions[] = ['extension' => 'quickhash', 'package' => 'derickr/quickhash']; + $this->runPieCommand(['download', 'asgrim/example-pie-extension', 'derickr/quickhash']); + } + #[When('I run a command to download version :version of an extension')] public function iRunACommandToDownloadSpecificVersionOfAnExtension(string $version): void { + $this->interactions[] = ['extension' => 'example_pie_extension', 'package' => 'asgrim/example-pie-extension']; $this->runPieCommand(['download', 'asgrim/example-pie-extension:' . $version]); } @@ -111,18 +120,25 @@ private function assertCommandSuccessful(): void } #[Then('the latest version should have been downloaded')] + #[Then('the extensions should have been downloaded')] public function theLatestVersionShouldHaveBeenDownloaded(): void { $this->assertCommandSuccessful(); - Assert::regex($this->output, '#Found package: asgrim/example-pie-extension:v?\d+\.\d+\.\d+ which provides ext-example_pie_extension#'); - Assert::regex($this->output, '#Extracted asgrim/example-pie-extension:v?\d+\.\d+\.\d+ source to: #'); + + foreach ($this->interactions as $downloads) { + Assert::regex($this->output, '#Found package: ' . $downloads['package'] . ':v?\d+\.\d+\.\d+ which provides ext-' . $downloads['extension'] . '#'); + Assert::regex($this->output, '#Extracted ' . $downloads['package'] . ':v?\d+\.\d+\.\d+ source to: #'); + } } #[Then('version :version should have been downloaded')] public function versionOfTheExtensionShouldHaveBeen(string $version): void { $this->assertCommandSuccessful(); - Assert::contains($this->output, 'Found package: asgrim/example-pie-extension:' . $version); + + foreach ($this->interactions as $downloads) { + Assert::contains($this->output, 'Found package: ' . $downloads['package'] . ':' . $version); + } } #[When('I run a command to build an extension')] @@ -132,7 +148,14 @@ public function iRunACommandToBuildAnExtension(): void $this->runPieCommand(['build', 'asgrim/example-pie-extension']); } + #[When('I run a command to build multiple extensions')] + public function iRunACommandToBuildMultipleExtensions(): void + { + $this->runPieCommand(['build', 'asgrim/example-pie-extension', 'derickr/quickhash']); + } + #[Then('the extension should have been built')] + #[Then('the extensions should have been built')] public function theExtensionShouldHaveBeenBuilt(): void { $this->assertCommandSuccessful(); @@ -188,53 +211,71 @@ public function theExtensionShouldHaveBeenBuiltWithOptions(): void #[Given('an extension was previously installed and enabled')] public function iRunACommandToInstallAnExtension(): void { - $this->theExtension = 'example_pie_extension'; - $this->thePackage = 'asgrim/example-pie-extension'; - $this->runPieCommand(['install', $this->thePackage]); + $this->interactions[] = ['extension' => 'example_pie_extension', 'package' => 'asgrim/example-pie-extension']; + $this->runPieCommand(['install', 'asgrim/example-pie-extension']); + } + + #[When('I run a command to install multiple extensions')] + #[Given('multiple extensions were previously installed and enabled')] + public function iRunACommandToInstallMultipleExtensions(): void + { + $this->interactions[] = ['extension' => 'example_pie_extension', 'package' => 'asgrim/example-pie-extension']; + $this->interactions[] = ['extension' => 'quickhash', 'package' => 'derickr/quickhash']; + $this->runPieCommand(['install', 'asgrim/example-pie-extension', 'derickr/quickhash']); } #[When('I run a command to forcefully install an extension')] public function iRunACommandToForcefullyInstallAnExtension(): void { - $this->theExtension = 'example_pie_extension'; - $this->thePackage = 'asgrim/example-pie-extension'; - $this->runPieCommand(['install', '--force', $this->thePackage]); + $this->interactions[] = ['extension' => 'example_pie_extension', 'package' => 'asgrim/example-pie-extension']; + $this->runPieCommand(['install', '--force', 'asgrim/example-pie-extension']); } #[When('I run a command to install an extension without enabling it')] public function iRunACommandToInstallAnExtensionWithoutEnabling(): void { - $this->theExtension = 'example_pie_extension'; - $this->thePackage = 'asgrim/example-pie-extension'; - $this->runPieCommand(['install', $this->thePackage, '--skip-enable-extension']); + $this->interactions[] = ['extension' => 'example_pie_extension', 'package' => 'asgrim/example-pie-extension']; + $this->runPieCommand(['install', 'asgrim/example-pie-extension', '--skip-enable-extension']); } #[When('I run a command to uninstall an extension')] public function iRunACommandToUninstallAnExtension(): void { - $this->runPieCommand(['uninstall', $this->thePackage]); + $this->interactions[] = ['extension' => 'example_pie_extension', 'package' => 'asgrim/example-pie-extension']; + $this->runPieCommand(['uninstall', 'asgrim/example-pie-extension']); + } + + #[When('I run a command to uninstall multiple extensions')] + public function iRunACommandToUninstallMultipleExtensions(): void + { + $this->interactions[] = ['extension' => 'example_pie_extension', 'package' => 'asgrim/example-pie-extension']; + $this->interactions[] = ['extension' => 'quickhash', 'package' => 'derickr/quickhash']; + $this->runPieCommand(['uninstall', 'asgrim/example-pie-extension', 'derickr/quickhash']); } #[Then('the extension should not be installed anymore')] + #[Then('the extensions should not be installed anymore')] public function theExtensionShouldNotBeInstalled(): void { $this->assertCommandSuccessful(); - if (Platform::isWindows()) { - Assert::regex($this->output, '#👋 Removed extension: [-\\\_:.a-zA-Z0-9]+\\\php_' . $this->theExtension . '.dll#'); - } else { - Assert::regex($this->output, '#👋 Removed extension: [-_.a-zA-Z0-9/]+/' . $this->theExtension . '.so#'); - } + foreach ($this->interactions as $uninstall) { + if (Platform::isWindows()) { + Assert::regex($this->output, '#👋 Removed extension: [-\\\_:.a-zA-Z0-9]+\\\php_' . $uninstall['extension'] . '.dll#'); + } else { + Assert::regex($this->output, '#👋 Removed extension: [-_.a-zA-Z0-9/]+/' . $uninstall['extension'] . '.so#'); + } - $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("' . $this->theExtension . '")?"yes":"no";'])) - ->mustRun() - ->getOutput(); + $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("' . $uninstall['extension'] . '")?"yes":"no";'])) + ->mustRun() + ->getOutput(); - Assert::same( - $isExtEnabled, - 'no', - sprintf("Failed to remove extension.\n\nOutput:\n%s\n\nError output:\n%s\n", $this->output, $this->errorOutput), - ); + Assert::same( + $isExtEnabled, + 'no', + sprintf("Failed to remove extension.\n\nOutput:\n%s\n\nError output:\n%s\n", $this->output, $this->errorOutput), + ); + } } #[Then('the extension should have been installed')] @@ -244,35 +285,40 @@ public function theExtensionShouldHaveBeenInstalled(): void Assert::contains($this->output, 'Extension has NOT been automatically enabled.'); - if (Platform::isWindows()) { - Assert::regex($this->output, '#Copied DLL to: [-\\\_:.a-zA-Z0-9]+\\\php_' . $this->theExtension . '.dll#'); + foreach ($this->interactions as $install) { + if (Platform::isWindows()) { + Assert::regex($this->output, '#Copied DLL to: [-\\\_:.a-zA-Z0-9]+\\\php_' . $install['extension'] . '.dll#'); - return; - } + continue; + } - Assert::regex($this->output, '#Install complete: [-_.a-zA-Z0-9/]+/' . $this->theExtension . '.so#'); + Assert::regex($this->output, '#Install complete: [-_.a-zA-Z0-9/]+/' . $install['extension'] . '.so#'); + } } #[Then('the extension should have been installed and enabled')] + #[Then('the extensions should have been installed and enabled')] public function theExtensionShouldHaveBeenInstalledAndEnabled(): void { $this->assertCommandSuccessful(); Assert::contains($this->output, 'Extension is enabled and loaded'); - if (Platform::isWindows()) { - Assert::regex($this->output, '#Copied DLL to: [-\\\_:.a-zA-Z0-9]+\\\php_' . $this->theExtension . '.dll#'); + foreach ($this->interactions as $install) { + if (Platform::isWindows()) { + Assert::regex($this->output, '#Copied DLL to: [-\\\_:.a-zA-Z0-9]+\\\php_' . $install['extension'] . '.dll#'); - return; - } + continue; + } - Assert::regex($this->output, '#Install complete: [-_.a-zA-Z0-9/]+/' . $this->theExtension . '.so#'); + Assert::regex($this->output, '#Install complete: [-_.a-zA-Z0-9/]+/' . $install['extension'] . '.so#'); - $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("' . $this->theExtension . '")?"yes":"no";'])) - ->mustRun() - ->getOutput(); + $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("' . $install['extension'] . '")?"yes":"no";'])) + ->mustRun() + ->getOutput(); - Assert::same($isExtEnabled, 'yes'); + Assert::same($isExtEnabled, 'yes'); + } } #[Then('the extension should not have been re-installed')] @@ -280,13 +326,15 @@ public function theExtensionShouldNotHaveBeenReinstalled(): void { $this->assertCommandSuccessful(); - Assert::contains($this->output, 'PIE package asgrim/example-pie-extension (example_pie_extension) is already installed and verified.'); + foreach ($this->interactions as $noops) { + Assert::contains($this->output, 'PIE package ' . $noops['package'] . ' (' . $noops['extension'] . ') is already installed and verified.'); - $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("' . $this->theExtension . '")?"yes":"no";'])) - ->mustRun() - ->getOutput(); + $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("' . $noops['extension'] . '")?"yes":"no";'])) + ->mustRun() + ->getOutput(); - Assert::same($isExtEnabled, 'yes'); + Assert::same($isExtEnabled, 'yes'); + } } #[Given('I have an invalid extension installed')] @@ -345,9 +393,8 @@ public function iHaveLibsodiumOnMySystem(): void #[Given('I have the sodium extension installed with PIE')] public function iInstallTheSodiumExtensionWithPie(): void { - $this->theExtension = 'sodium'; - $this->thePackage = 'php/sodium'; - $this->runPieCommand(['install', $this->thePackage]); + $this->interactions[] = ['extension' => 'sodium', 'package' => 'php/sodium']; + $this->runPieCommand(['install', 'php/sodium']); } #[Given('I do not have libsodium on my system')] @@ -359,9 +406,8 @@ public function iDoNotHaveLibsodiumOnMySystem(): void #[When('I display information about the sodium extension with PIE')] public function iDisplayInformationAboutTheSodiumExtensionWithPie(): void { - $this->theExtension = 'sodium'; - $this->thePackage = 'php/sodium'; - $this->runPieCommand(['info', $this->thePackage]); + $this->interactions[] = ['extension' => 'sodium', 'package' => 'php/sodium']; + $this->runPieCommand(['info', 'php/sodium']); } #[Then('the information should show that libsodium is a missing dependency')] @@ -445,8 +491,8 @@ public function iAmInAPIEProject(): void #[When('I run a command to install the extensions without package selections')] public function iRunACommandToInstallTheExtension(): void { - $this->theExtension = 'example_pie_extension'; - $this->thePackage = 'asgrim/example-pie-extension'; + // Note: implied from composer.json, we don't explicitly request the packages here + $this->interactions[] = ['extension' => 'example_pie_extension', 'package' => 'asgrim/example-pie-extension']; $this->runPieCommand(['install']); } From 9d456d64fce3144c39b07049a69ee8eec4668ca8 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Mon, 22 Jun 2026 09:27:29 +0100 Subject: [PATCH 07/22] 466: update broken usages of PieComposerRequest->requestedPackage to use list of packages --- .../OverrideDownloadUrlInstallListener.php | 2 +- .../RemoveUnrelatedInstallOperations.php | 2 +- src/ComposerIntegration/PieComposerRequest.php | 12 ++++++++++++ src/ComposerIntegration/PiePackageInstaller.php | 15 +++++++++------ 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php b/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php index e0f41347..c65a204b 100644 --- a/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php +++ b/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php @@ -66,7 +66,7 @@ function (OperationInterface $operation): void { } // Install requests for other packages than the one we want should be ignored - if ($this->composerRequest->requestedPackage->package !== $composerPackage->getName()) { + if (! $this->composerRequest->isFor($composerPackage->getName())) { return; } diff --git a/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php b/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php index c26e7384..43850e7b 100644 --- a/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php +++ b/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php @@ -58,7 +58,7 @@ function (OperationInterface $operation) use ($pieOutput): bool { return false; } - $isRequestedPiePackage = $this->composerRequest->requestedPackage->package === $operation->getPackage()->getName(); + $isRequestedPiePackage = $this->composerRequest->isFor($operation->getPackage()->getName()); if (! $isRequestedPiePackage) { $pieOutput->writeError( diff --git a/src/ComposerIntegration/PieComposerRequest.php b/src/ComposerIntegration/PieComposerRequest.php index 8964263b..b44978e5 100644 --- a/src/ComposerIntegration/PieComposerRequest.php +++ b/src/ComposerIntegration/PieComposerRequest.php @@ -8,6 +8,9 @@ use Php\Pie\DependencyResolver\RequestedPackageAndVersion; use Php\Pie\Platform\TargetPlatform; +use function array_map; +use function in_array; + /** * @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks * @@ -15,6 +18,9 @@ */ final class PieComposerRequest { + /** @var list */ + private readonly array $requestedPackageNames; + /** * @param list $requestedPackages * @param list $configureOptions @@ -27,6 +33,7 @@ public function __construct( public readonly array $configureOptions, public readonly bool $attemptToSetupIniFile, ) { + $this->requestedPackageNames = array_map(static fn (RequestedPackageAndVersion $request) => $request->package, $this->requestedPackages); } /** @@ -46,4 +53,9 @@ public static function noOperation( false, ); } + + public function isFor(string $packageName): bool + { + return in_array($packageName, $this->requestedPackageNames); + } } diff --git a/src/ComposerIntegration/PiePackageInstaller.php b/src/ComposerIntegration/PiePackageInstaller.php index bec8b961..8fb583e8 100644 --- a/src/ComposerIntegration/PiePackageInstaller.php +++ b/src/ComposerIntegration/PiePackageInstaller.php @@ -11,8 +11,11 @@ use Composer\PartialComposer; use Composer\Repository\InstalledRepositoryInterface; use Composer\Util\Filesystem; +use Php\Pie\DependencyResolver\RequestedPackageAndVersion; use Php\Pie\ExtensionType; +use function array_map; +use function implode; use function sprintf; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ @@ -39,12 +42,12 @@ public function install(InstalledRepositoryInterface $repo, PackageInterface $pa ?->then(function () use ($composerPackage) { $io = $this->composerRequest->pieOutput; - if ($this->composerRequest->requestedPackage->package !== $composerPackage->getName()) { + if (! $this->composerRequest->isFor($composerPackage->getName())) { $io->write( sprintf( - 'Skipping %s install request from Composer as it was not the expected PIE package %s', + 'Skipping %s install request from Composer as it was not the expected PIE package(s) %s', $composerPackage->getName(), - $this->composerRequest->requestedPackage->package, + implode(', ', array_map(static fn (RequestedPackageAndVersion $req) => $req->package, $this->composerRequest->requestedPackages)), ), verbosity: IOInterface::VERY_VERBOSE, ); @@ -81,12 +84,12 @@ public function uninstall(InstalledRepositoryInterface $repo, PackageInterface $ ?->then(function () use ($composerPackage) { $io = $this->composerRequest->pieOutput; - if ($this->composerRequest->requestedPackage->package !== $composerPackage->getName()) { + if (! $this->composerRequest->isFor($composerPackage->getName())) { $io->write( sprintf( - 'Skipping %s uninstall request from Composer as it was not the expected PIE package %s', + 'Skipping %s uninstall request from Composer as it was not the expected PIE package(s) %s', $composerPackage->getName(), - $this->composerRequest->requestedPackage->package, + implode(', ', array_map(static fn (RequestedPackageAndVersion $req) => $req->package, $this->composerRequest->requestedPackages)), ), verbosity: IOInterface::VERY_VERBOSE, ); From 289d56a6dabd37c8d48d89625a30d76b0935c140 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Mon, 22 Jun 2026 11:32:24 +0100 Subject: [PATCH 08/22] 466: updated uninstall command and composer integration to work with multiple packages --- src/Command/UninstallCommand.php | 54 ++++++++++--------- .../ComposerIntegrationHandler.php | 21 +++++--- test/behaviour/CliContext.php | 4 +- 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/src/Command/UninstallCommand.php b/src/Command/UninstallCommand.php index 86b2bf67..4b6398d5 100644 --- a/src/Command/UninstallCommand.php +++ b/src/Command/UninstallCommand.php @@ -7,12 +7,14 @@ use Composer\Composer; use Composer\IO\IOInterface; use Composer\IO\NullIO; +use OutOfRangeException; use Php\Pie\ComposerIntegration\ComposerIntegrationHandler; use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; use Php\Pie\DependencyResolver\Package; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; +use Php\Pie\DependencyResolver\ResolvedPackageRequest; use Php\Pie\Platform\InstalledPiePackages; use Php\Pie\Platform\TargetPlatform; use Psr\Container\ContainerInterface; @@ -23,13 +25,15 @@ use Symfony\Component\Console\Output\OutputInterface; use Webmozart\Assert\Assert; +use function array_map; + #[AsCommand( name: 'uninstall', description: 'Disable and remove an extension that has been installed with PIE', )] final class UninstallCommand extends Command { - private const ARG_PACKAGE_NAME = 'package-name'; + private const ARG_PACKAGE_NAMES = 'package-name'; public function __construct( private readonly InstalledPiePackages $installedPiePackages, @@ -45,9 +49,9 @@ public function configure(): void parent::configure(); $this->addArgument( - self::ARG_PACKAGE_NAME, - InputArgument::REQUIRED, - 'The package name to remove, in the format {vendor/package}, for example `xdebug/xdebug`', + self::ARG_PACKAGE_NAMES, + InputArgument::REQUIRED | InputArgument::IS_ARRAY, + 'The package names to remove, in the format {vendor/package}, for example `xdebug/xdebug`', ); CommandHelper::configurePhpConfigOptions($this); @@ -59,9 +63,13 @@ public function execute(InputInterface $input, OutputInterface $output): int $this->io->write('This command may need elevated privileges, and may prompt you for your password.'); } - $packageToRemove = (string) $input->getArgument(self::ARG_PACKAGE_NAME); - Assert::stringNotEmpty($packageToRemove); - $requestedPackageAndVersionToRemove = new RequestedPackageAndVersion($packageToRemove, null); + $packagesToRemove = $input->getArgument(self::ARG_PACKAGE_NAMES); + Assert::isList($packagesToRemove); + Assert::allStringNotEmpty($packagesToRemove); + $requestedPackageAndVersionsToRemove = array_map( + static fn (string $packageName) => new RequestedPackageAndVersion($packageName, null), + $packagesToRemove, + ); $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); @@ -75,10 +83,18 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); - $piePackage = $this->findPiePackageByPackageName($packageToRemove, $composer); + $piePackages = $this->installedPiePackages->allPiePackages($composer); - if ($piePackage === null) { - $this->io->writeError('No package found: ' . $packageToRemove . ''); + try { + $resolvedPackages = array_map( + static fn (RequestedPackageAndVersion $req) => new ResolvedPackageRequest( + $piePackages->findByPackageName($req->package), + $req, + ), + $requestedPackageAndVersionsToRemove, + ); + } catch (OutOfRangeException $exception) { + $this->io->writeError('No package found: ' . $exception->getMessage() . ''); return 1; } @@ -88,7 +104,7 @@ public function execute(InputInterface $input, OutputInterface $output): int new PieComposerRequest( $this->io, $targetPlatform, - $requestedPackageAndVersionToRemove, + $requestedPackageAndVersionsToRemove, PieOperation::Uninstall, [], // Configure options are not needed for uninstall true, @@ -96,25 +112,11 @@ public function execute(InputInterface $input, OutputInterface $output): int ); $this->composerIntegrationHandler->runUninstall( - $piePackage, + $resolvedPackages, $composer, $targetPlatform, - $requestedPackageAndVersionToRemove, ); return 0; } - - private function findPiePackageByPackageName(string $packageToRemove, Composer $composer): Package|null - { - $piePackages = $this->installedPiePackages->allPiePackages($composer); - - foreach ($piePackages->packages() as $piePackage) { - if ($piePackage->name() === $packageToRemove) { - return $piePackage; - } - } - - return null; - } } diff --git a/src/ComposerIntegration/ComposerIntegrationHandler.php b/src/ComposerIntegration/ComposerIntegrationHandler.php index 7069a20e..306f6f1f 100644 --- a/src/ComposerIntegration/ComposerIntegrationHandler.php +++ b/src/ComposerIntegration/ComposerIntegrationHandler.php @@ -11,7 +11,6 @@ use Composer\Package\CompleteAliasPackage; use Composer\Package\CompletePackageInterface; use Php\Pie\DependencyResolver\Package; -use Php\Pie\DependencyResolver\RequestedPackageAndVersion; use Php\Pie\DependencyResolver\ResolvedPackageRequest; use Php\Pie\ExtensionName; use Php\Pie\Platform; @@ -189,23 +188,30 @@ public function runInstall( ($this->vendorCleanup)($composer); } + /** @param list $resolvedPackagesToRemove */ public function runUninstall( - Package $packageToRemove, + array $resolvedPackagesToRemove, Composer $composer, TargetPlatform $targetPlatform, - RequestedPackageAndVersion $requestedPackageAndVersionToRemove, ): void { // Write the new requirement to pie.json; because we later essentially just do a `composer install` using that file $pieComposerJson = Platform::getPieJsonFilename($targetPlatform); $pieJsonEditor = PieJsonEditor::fromTargetPlatform($targetPlatform); - $originalPieJsonContent = $pieJsonEditor->removeRequire($requestedPackageAndVersionToRemove->package); + $originalPieJsonContent = $pieJsonEditor->currentContent(); + + foreach ($resolvedPackagesToRemove as $resolvedPackageRequest) { + $pieJsonEditor->removeRequire($resolvedPackageRequest->requestedPackageAndVersion->package); + } // Refresh the Composer instance so it re-reads the updated pie.json $composer = PieComposerFactory::recreatePieComposer($this->container, $composer); $composerInstaller = PieComposerInstaller::createWithPhpBinary( $targetPlatform->phpBinaryPath, - $packageToRemove->extensionName(), + array_map( + static fn (ResolvedPackageRequest $resolvedPackageRequest) => $resolvedPackageRequest->piePackage->extensionName(), + $resolvedPackagesToRemove, + ), $this->arrayCollectionIo, $composer, ); @@ -218,7 +224,10 @@ public function runUninstall( if (file_exists(PieComposerFactory::getLockFile($pieComposerJson))) { $composerInstaller->setUpdate(true); - $composerInstaller->setUpdateAllowList([$requestedPackageAndVersionToRemove->package]); + $composerInstaller->setUpdateAllowList(array_map( + static fn (ResolvedPackageRequest $resolvedPackageRequest) => $resolvedPackageRequest->requestedPackageAndVersion->package, + $resolvedPackagesToRemove, + )); } $resultCode = $composerInstaller->run(); diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index 84c1dba0..f79045e5 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -14,6 +14,7 @@ use Symfony\Component\Process\Process; use Webmozart\Assert\Assert; +use function array_map; use function array_merge; use function Safe\copy; use function Safe\preg_match_all; @@ -241,8 +242,7 @@ public function iRunACommandToInstallAnExtensionWithoutEnabling(): void #[When('I run a command to uninstall an extension')] public function iRunACommandToUninstallAnExtension(): void { - $this->interactions[] = ['extension' => 'example_pie_extension', 'package' => 'asgrim/example-pie-extension']; - $this->runPieCommand(['uninstall', 'asgrim/example-pie-extension']); + $this->runPieCommand(['uninstall', ...array_map(static fn (array $interaction) => $interaction['package'], $this->interactions)]); } #[When('I run a command to uninstall multiple extensions')] From 7cd8327e009bb9633503be57ee33947efed28f7e Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Mon, 22 Jun 2026 11:38:48 +0100 Subject: [PATCH 09/22] 466: fixing up ShowCommand to use resolved package VO --- src/Command/ShowCommand.php | 8 ++++---- test/integration/Command/ShowCommandTest.php | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Command/ShowCommand.php b/src/Command/ShowCommand.php index ec457f09..d43f5f41 100644 --- a/src/Command/ShowCommand.php +++ b/src/Command/ShowCommand.php @@ -151,16 +151,16 @@ function (string $version, string $phpExtensionName) use ($composer, $rootPackag } $updateNotice = ''; - if ($latestConstrainedPackage !== null && $latestConstrainedPackage->version() !== $piePackage->version()) { + if ($latestConstrainedPackage !== null && $latestConstrainedPackage->piePackage->version() !== $piePackage->version()) { $updateNotice = sprintf( ', upgradable to %s (within %s)', - $latestConstrainedPackage->version(), + $latestConstrainedPackage->piePackage->version(), $packageRequirement, ); } - if ($latestPackage !== null && $latestPackage->version() !== $latestConstrainedPackage->version()) { - $updateNotice .= sprintf(', latest version is %s', $latestPackage->version()); + if ($latestPackage !== null && $latestPackage->piePackage->version() !== $latestConstrainedPackage->piePackage->version()) { + $updateNotice .= sprintf(', latest version is %s', $latestPackage->piePackage->version()); } $this->io->write(sprintf( diff --git a/test/integration/Command/ShowCommandTest.php b/test/integration/Command/ShowCommandTest.php index c097b402..c1d2370e 100644 --- a/test/integration/Command/ShowCommandTest.php +++ b/test/integration/Command/ShowCommandTest.php @@ -65,7 +65,7 @@ public function testExecuteWithAvailableConstrainedUpdates(): void $installCommand = new CommandTester(Container::testFactory()->get(InstallCommand::class)); $installCommand->execute([ - 'requested-package-and-version' => self::TEST_PACKAGE . ':2.0.2', + 'requested-package-and-version' => [self::TEST_PACKAGE . ':2.0.2'], '--with-php-config' => $phpConfig, ]); $installCommand->assertCommandIsSuccessful(); @@ -113,7 +113,7 @@ public function testExecuteWithAvailableUnconstrainedUpdates(): void $installCommand = new CommandTester(Container::testFactory()->get(InstallCommand::class)); $installCommand->execute([ - 'requested-package-and-version' => self::TEST_PACKAGE . ':2.0.2', + 'requested-package-and-version' => [self::TEST_PACKAGE . ':2.0.2'], '--with-php-config' => $phpConfig, ]); $installCommand->assertCommandIsSuccessful(); @@ -161,7 +161,7 @@ public function testExecuteWithOnlyUnconstrainedUpdates(): void $installCommand = new CommandTester(Container::testFactory()->get(InstallCommand::class)); $installCommand->execute([ - 'requested-package-and-version' => self::TEST_PACKAGE . ':2.0.2', + 'requested-package-and-version' => [self::TEST_PACKAGE . ':2.0.2'], '--with-php-config' => $phpConfig, ]); $installCommand->assertCommandIsSuccessful(); From 7b3983e66923ccad634a32bc0efa7dc961a3634a Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Mon, 22 Jun 2026 12:06:49 +0100 Subject: [PATCH 10/22] 466: requested-package-and-version is an array now --- src/Command/InstallExtensionsForProjectCommand.php | 1 + .../InstallForPhpProject/InstallPiePackageFromPath.php | 2 +- src/Installing/InstallForPhpProject/InstallSelectedPackage.php | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index bd33fd1d..2bde5e79 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -224,6 +224,7 @@ private function handleSingleExtensionRequiredByPhpProject( } try { + // @todo instead of individually installing them, we can now do `pie install ...` $this->io->write( sprintf('Invoking pie install of %s', $requestedPackageAndVersion->prettyNameAndVersion()), verbosity: IOInterface::VERBOSE, diff --git a/src/Installing/InstallForPhpProject/InstallPiePackageFromPath.php b/src/Installing/InstallForPhpProject/InstallPiePackageFromPath.php index 3e8771fc..4aeb5291 100644 --- a/src/Installing/InstallForPhpProject/InstallPiePackageFromPath.php +++ b/src/Installing/InstallForPhpProject/InstallPiePackageFromPath.php @@ -39,7 +39,7 @@ public function __invoke( $invokeContext, [ 'command' => 'install', - 'requested-package-and-version' => $pieRootPackage->getName() . ':*@dev', + 'requested-package-and-version' => [$pieRootPackage->getName() . ':*@dev'], ], $input, ); diff --git a/src/Installing/InstallForPhpProject/InstallSelectedPackage.php b/src/Installing/InstallForPhpProject/InstallSelectedPackage.php index 97d64207..898a0ec9 100644 --- a/src/Installing/InstallForPhpProject/InstallSelectedPackage.php +++ b/src/Installing/InstallForPhpProject/InstallSelectedPackage.php @@ -27,7 +27,7 @@ public function withSubCommand( ): int { $params = [ 'command' => 'install', - 'requested-package-and-version' => $selectedPackage->prettyNameAndVersion(), + 'requested-package-and-version' => [$selectedPackage->prettyNameAndVersion()], ]; return ($this->invokeSubCommand)( From cc90df09541499971d22538bf5fad411936436d7 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 24 Jun 2026 07:52:35 +0100 Subject: [PATCH 11/22] 466: when installing for a PHP project, invoke pie install instead of pie install individually --- .../InstallExtensionsForProjectCommand.php | 94 ++++++++++--------- src/Command/UninstallCommand.php | 2 - .../InstallSelectedPackage.php | 16 ++-- 3 files changed, 62 insertions(+), 50 deletions(-) diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 2bde5e79..884b161c 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -12,6 +12,7 @@ use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieJsonEditor; +use Php\Pie\DependencyResolver\RequestedPackageAndVersion; use Php\Pie\ExtensionName; use Php\Pie\ExtensionType; use Php\Pie\Installing\InstallForPhpProject\CheckExtensionStatus; @@ -36,9 +37,11 @@ use Throwable; use Webmozart\Assert\Assert; +use function array_filter; use function array_keys; use function array_map; -use function array_walk; +use function array_values; +use function count; use function implode; use function Safe\getcwd; use function Safe\realpath; @@ -136,26 +139,56 @@ private function handlePhpProject(InputInterface $input, RootPackageInterface $r $anyErrorsHappened = false; - array_walk( + $scheduledForInstall = array_values(array_filter(array_map( + function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePackages, &$anyErrorsHappened, $targetPlatform, $extensionToPackageSelections): RequestedPackageAndVersion|null { + $result = $this->handleSingleExtensionRequiredByPhpProject( + $pieComposer, + $link, + $installedPiePackages, + $targetPlatform, + $phpEnabledExtensions, + $extensionToPackageSelections, + ); + + if ($result instanceof RequestedPackageAndVersion) { + return $result; + } + + if ($result === false) { + $anyErrorsHappened = true; + } + + return null; + }, $extensionsRequired, - function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePackages, $input, &$anyErrorsHappened, $targetPlatform, $extensionToPackageSelections): void { - if ( - $this->handleSingleExtensionRequiredByPhpProject( + ))); + + if (count($scheduledForInstall)) { + $nicePackageList = implode(', ', array_map( + static fn (RequestedPackageAndVersion $req) => $req->prettyNameAndVersion(), + $scheduledForInstall, + )); + + try { + $this->io->write( + sprintf('Invoking pie install of %s', $nicePackageList), + verbosity: IOInterface::VERBOSE, + ); + Assert::same( + 0, + $this->installSelectedPackage->withSubCommand( + $scheduledForInstall, + $this, $input, - $pieComposer, - $link, - $installedPiePackages, - $targetPlatform, - $phpEnabledExtensions, - $extensionToPackageSelections, - ) - ) { - return; - } + ), + 'Non-zero exit code %s whilst installing ' . $nicePackageList, + ); + } catch (Throwable $t) { + $this->io->writeError('' . $t->getMessage() . ''); $anyErrorsHappened = true; - }, - ); + } + } $this->io->write(PHP_EOL . 'Finished checking extensions.'); @@ -172,14 +205,13 @@ function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePac * @param array $extensionToPackageSelections */ private function handleSingleExtensionRequiredByPhpProject( - InputInterface $input, Composer $pieComposer, Link $link, PiePackageList $installedPiePackages, TargetPlatform $targetPlatform, array $phpEnabledExtensions, array $extensionToPackageSelections, - ): bool { + ): RequestedPackageAndVersion|bool { $extension = ExtensionName::normaliseFromString($link->getTarget()); $piePackagesForExtension = $installedPiePackages ->findByPhpFormattedExtensionName($extension->phpFormattedExtensionName()) @@ -223,29 +255,7 @@ private function handleSingleExtensionRequiredByPhpProject( return false; } - try { - // @todo instead of individually installing them, we can now do `pie install ...` - $this->io->write( - sprintf('Invoking pie install of %s', $requestedPackageAndVersion->prettyNameAndVersion()), - verbosity: IOInterface::VERBOSE, - ); - Assert::same( - 0, - $this->installSelectedPackage->withSubCommand( - $extension, - $requestedPackageAndVersion, - $this, - $input, - ), - 'Non-zero exit code %s whilst installing ' . $requestedPackageAndVersion->package, - ); - - return true; - } catch (Throwable $t) { - $this->io->writeError('' . $t->getMessage() . ''); - - return false; - } + return $requestedPackageAndVersion; } public function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Command/UninstallCommand.php b/src/Command/UninstallCommand.php index 4b6398d5..cfe4604a 100644 --- a/src/Command/UninstallCommand.php +++ b/src/Command/UninstallCommand.php @@ -4,7 +4,6 @@ namespace Php\Pie\Command; -use Composer\Composer; use Composer\IO\IOInterface; use Composer\IO\NullIO; use OutOfRangeException; @@ -12,7 +11,6 @@ use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; -use Php\Pie\DependencyResolver\Package; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; use Php\Pie\DependencyResolver\ResolvedPackageRequest; use Php\Pie\Platform\InstalledPiePackages; diff --git a/src/Installing/InstallForPhpProject/InstallSelectedPackage.php b/src/Installing/InstallForPhpProject/InstallSelectedPackage.php index 898a0ec9..e50f8cca 100644 --- a/src/Installing/InstallForPhpProject/InstallSelectedPackage.php +++ b/src/Installing/InstallForPhpProject/InstallSelectedPackage.php @@ -6,11 +6,11 @@ use Php\Pie\Command\InvokeSubCommand; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; -use Php\Pie\ExtensionName; -use Php\Pie\Util\OutputFormatterWithPrefix; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use function array_map; + /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ class InstallSelectedPackage { @@ -19,22 +19,26 @@ public function __construct( ) { } + /** @param list $selectedPackages */ public function withSubCommand( - ExtensionName $ext, - RequestedPackageAndVersion $selectedPackage, + array $selectedPackages, Command $command, InputInterface $input, ): int { $params = [ 'command' => 'install', - 'requested-package-and-version' => [$selectedPackage->prettyNameAndVersion()], + 'requested-package-and-version' => [ + ...array_map( + static fn (RequestedPackageAndVersion $package) => $package->prettyNameAndVersion(), + $selectedPackages, + ), + ], ]; return ($this->invokeSubCommand)( $command, $params, $input, - OutputFormatterWithPrefix::newWithPrefix(' ' . $ext->name() . '> '), ); } } From 3dfaade287d19ed034117be3c2d0d574357df0cc Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 24 Jun 2026 08:16:21 +0100 Subject: [PATCH 12/22] 466: Fix system dependency prescan --- src/Command/BuildCommand.php | 34 ++++++++++--------- src/Command/InstallCommand.php | 34 ++++++++++--------- .../PrescanSystemDependencies.php | 2 +- 3 files changed, 37 insertions(+), 33 deletions(-) diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index deb53dfc..6b82eb71 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -23,6 +23,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; #[AsCommand( name: 'build', @@ -88,22 +89,23 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); - // @todo fix this -// if (CommandHelper::shouldCheckSystemDependencies($input)) { -// try { -// ($this->prescanSystemDependencies)( -// $composer, -// $targetPlatform, -// $requestedNamesAndVersions, -// CommandHelper::autoInstallSystemDependencies($input), -// ); -// } catch (Throwable $anything) { -// $this->io->writeError( -// 'Skipping system dependency pre-scan due to exception: ' . $anything->getMessage(), -// verbosity: IOInterface::VERBOSE, -// ); -// } -// } + if (CommandHelper::shouldCheckSystemDependencies($input)) { + foreach ($requestedNamesAndVersions as $requestedNameAndVersion) { + try { + ($this->prescanSystemDependencies)( + $composer, + $targetPlatform, + $requestedNameAndVersion, + CommandHelper::autoInstallSystemDependencies($input), + ); + } catch (Throwable $anything) { + $this->io->writeError( + 'Skipping system dependency pre-scan due to exception: ' . $anything->getMessage(), + verbosity: IOInterface::VERBOSE, + ); + } + } + } try { $resolvedPackages = CommandHelper::resolveRequestedPackages( diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 3c37f1b9..21a09eae 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -24,6 +24,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; #[AsCommand( name: 'install', @@ -102,22 +103,23 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); - // @todo fix this -// if (CommandHelper::shouldCheckSystemDependencies($input)) { -// try { -// ($this->prescanSystemDependencies)( -// $composer, -// $targetPlatform, -// $requestedNamesAndVersions, -// CommandHelper::autoInstallSystemDependencies($input), -// ); -// } catch (Throwable $anything) { -// $this->io->writeError( -// 'Skipping system dependency pre-scan due to exception: ' . $anything->getMessage(), -// verbosity: IOInterface::VERBOSE, -// ); -// } -// } + if (CommandHelper::shouldCheckSystemDependencies($input)) { + foreach ($requestedNamesAndVersions as $requestedNameAndVersion) { + try { + ($this->prescanSystemDependencies)( + $composer, + $targetPlatform, + $requestedNameAndVersion, + CommandHelper::autoInstallSystemDependencies($input), + ); + } catch (Throwable $anything) { + $this->io->writeError( + 'Skipping system dependency pre-scan due to exception: ' . $anything->getMessage(), + verbosity: IOInterface::VERBOSE, + ); + } + } + } try { $resolvedPackages = CommandHelper::resolveRequestedPackages( diff --git a/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php b/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php index 4b3a303e..d3fff14e 100644 --- a/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php +++ b/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php @@ -58,7 +58,7 @@ public function __invoke( ); $unmetDependencies = array_filter( - ($this->fetchDependencyStatuses)($targetPlatform, $composer, $package->composerPackage()), + ($this->fetchDependencyStatuses)($targetPlatform, $composer, $package->piePackage->composerPackage()), static function (DependencyStatus $dependencyStatus): bool { return ! $dependencyStatus->satisfied(); }, From 507d632fdfa4718159b78510e3d156ec18141781 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 24 Jun 2026 08:19:53 +0100 Subject: [PATCH 13/22] 466: fix usages of PieComposerRequest/Platform repository now needing a list of packages --- test/behaviour/CliContext.php | 2 +- .../ResolveDependencyWithComposerTest.php | 2 +- .../AddInstalledJsonMetadataTest.php | 4 ++-- .../InstallAndBuildProcessTest.php | 6 ++--- ...OverrideDownloadUrlInstallListenerTest.php | 22 +++++++++---------- .../RemoveUnrelatedInstallOperationsTest.php | 4 ++-- ...pBinaryPathBasedPlatformRepositoryTest.php | 8 +++---- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index f79045e5..5fb1b59b 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -32,7 +32,7 @@ class CliContext implements Context private int|null $exitCode = null; /** @var list */ private array $phpArguments = []; - /** @var list */ + /** @var list */ private array $interactions = []; private string|null $workingDirectory = null; diff --git a/test/integration/DependencyResolver/ResolveDependencyWithComposerTest.php b/test/integration/DependencyResolver/ResolveDependencyWithComposerTest.php index 750e7109..778089f8 100644 --- a/test/integration/DependencyResolver/ResolveDependencyWithComposerTest.php +++ b/test/integration/DependencyResolver/ResolveDependencyWithComposerTest.php @@ -83,7 +83,7 @@ public function testDependenciesAreResolvedToExpectedVersions( new PieComposerRequest( $this->createMock(IOInterface::class), $targetPlatform, - $requestedPackageAndVersion, + [$requestedPackageAndVersion], PieOperation::Resolve, [], false, diff --git a/test/unit/ComposerIntegration/AddInstalledJsonMetadataTest.php b/test/unit/ComposerIntegration/AddInstalledJsonMetadataTest.php index 111f2324..e9f7684c 100644 --- a/test/unit/ComposerIntegration/AddInstalledJsonMetadataTest.php +++ b/test/unit/ComposerIntegration/AddInstalledJsonMetadataTest.php @@ -62,7 +62,7 @@ public function testMetadataForDownloads(): void null, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.0'), + [new RequestedPackageAndVersion('foo/bar', '^1.0')], PieOperation::Build, ['--foo', '--bar="yes"'], false, @@ -101,7 +101,7 @@ public function testMetadataForBuilds(): void null, new PhpizePath('/path/to/phpize'), ), - new RequestedPackageAndVersion('foo/bar', '^1.0'), + [new RequestedPackageAndVersion('foo/bar', '^1.0')], PieOperation::Build, ['--foo', '--bar="yes"'], false, diff --git a/test/unit/ComposerIntegration/InstallAndBuildProcessTest.php b/test/unit/ComposerIntegration/InstallAndBuildProcessTest.php index be713a05..e8f913d7 100644 --- a/test/unit/ComposerIntegration/InstallAndBuildProcessTest.php +++ b/test/unit/ComposerIntegration/InstallAndBuildProcessTest.php @@ -66,7 +66,7 @@ public function testDownloadWithoutBuildAndInstall(): void null, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.0'), + [new RequestedPackageAndVersion('foo/bar', '^1.0')], PieOperation::Download, ['--foo', '--bar="yes"'], false, @@ -107,7 +107,7 @@ public function testDownloadAndBuildWithoutInstall(): void null, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.0'), + [new RequestedPackageAndVersion('foo/bar', '^1.0')], PieOperation::Build, ['--foo', '--bar="yes"'], false, @@ -151,7 +151,7 @@ public function testDownloadBuildAndInstall(): void null, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.0'), + [new RequestedPackageAndVersion('foo/bar', '^1.0')], PieOperation::Install, ['--foo', '--bar="yes"'], false, diff --git a/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php b/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php index f0241c27..6ec330e6 100644 --- a/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php +++ b/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php @@ -80,7 +80,7 @@ public function testEventListenerRegistration(): void WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.1'), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, [], false, @@ -122,7 +122,7 @@ public function testNonInstallOperationsAreIgnored(): void WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.1'), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, [], false, @@ -164,7 +164,7 @@ public function testNonCompletePackagesAreIgnored(): void WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.1'), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, [], false, @@ -206,7 +206,7 @@ public function testInstallOperationsForDifferentPackagesAreIgnored(): void WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.1'), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, [], false, @@ -248,7 +248,7 @@ public function testWindowsUrlInstallerDoesNotRunOnNonWindows(): void WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.1'), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, [], false, @@ -303,7 +303,7 @@ public function testDistUrlIsUpdatedForWindowsInstallers(): void WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.1'), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, [], false, @@ -363,7 +363,7 @@ public function testDistUrlIsUpdatedForPrePackagedTgzSource(): void WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.1'), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, [], false, @@ -424,7 +424,7 @@ public function testDistUrlIsUpdatedForPrePackagedTgzBinaryWhenBinaryIsFound(): WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.1'), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, [], false, @@ -485,7 +485,7 @@ public function testDistUrlIsUpdatedForPrePackagedTgzBinaryWhenBinaryIsNotFound( WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.1'), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, [], false, @@ -539,7 +539,7 @@ public function testPrePackagedBinaryMethodIsIgnoredWhenConfigureOptionsArePasse WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.1'), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, ['--with-foo'], false, @@ -597,7 +597,7 @@ public function testNoSelectedDownloadUrlMethodWillThrowException(): void WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.1'), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, [], false, diff --git a/test/unit/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperationsTest.php b/test/unit/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperationsTest.php index e6c1d733..b7fe028b 100644 --- a/test/unit/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperationsTest.php +++ b/test/unit/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperationsTest.php @@ -73,7 +73,7 @@ public function testEventListenerRegistration(): void WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('foo/bar', '^1.1'), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, [], false, @@ -109,7 +109,7 @@ public function testUnrelatedInstallOperationsAreRemoved(): void WindowsCompiler::VC15, null, ), - new RequestedPackageAndVersion('bat/baz', '^3.2'), + [new RequestedPackageAndVersion('bat/baz', '^3.2')], PieOperation::Install, [], false, diff --git a/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php b/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php index e827f201..ca1b5d4b 100644 --- a/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php +++ b/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php @@ -51,7 +51,7 @@ public function testPlatformRepositoryContainsExpectedPacakges(): void 'another' => '1.2.3-alpha.34', ]); - $platformRepository = new PhpBinaryPathBasedPlatformRepository($phpBinaryPath, $composer, $installedPiePackages, null); + $platformRepository = new PhpBinaryPathBasedPlatformRepository($phpBinaryPath, $composer, $installedPiePackages, []); self::assertSame( [ @@ -91,7 +91,7 @@ public function testPlatformRepositoryExcludesExtensionBeingInstalled(): void 'extension_being_installed' => '1.2.3', ]); - $platformRepository = new PhpBinaryPathBasedPlatformRepository($phpBinaryPath, $composer, $installedPiePackages, $extensionBeingInstalled); + $platformRepository = new PhpBinaryPathBasedPlatformRepository($phpBinaryPath, $composer, $installedPiePackages, [$extensionBeingInstalled]); self::assertSame( [ @@ -134,7 +134,7 @@ public function testPlatformRepositoryExcludesReplacedExtensions(): void 'replaced_extension' => '3.0.0', ]); - $platformRepository = new PhpBinaryPathBasedPlatformRepository($phpBinaryPath, $composer, $installedPiePackages, $extensionBeingInstalled); + $platformRepository = new PhpBinaryPathBasedPlatformRepository($phpBinaryPath, $composer, $installedPiePackages, [$extensionBeingInstalled]); self::assertSame( [ @@ -228,7 +228,7 @@ public function testLibrariesAreIncluded(string $packageName): void PhpBinaryPath::fromCurrentProcess(), $this->createMock(Composer::class), $installedPiePackages, - ExtensionName::normaliseFromString('extension_being_installed'), + [ExtensionName::normaliseFromString('extension_being_installed')], ))->getPackages(), ), )); From f79ba35fbf39209d26a5ecc397223857e6b73b2b Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 24 Jun 2026 08:23:40 +0100 Subject: [PATCH 14/22] 466: fix usages of package needing ->piePackage now --- .../ResolveDependencyWithComposerTest.php | 6 +++--- .../ResolveDependencyWithComposerTest.php | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/test/integration/DependencyResolver/ResolveDependencyWithComposerTest.php b/test/integration/DependencyResolver/ResolveDependencyWithComposerTest.php index 778089f8..0cd2aed7 100644 --- a/test/integration/DependencyResolver/ResolveDependencyWithComposerTest.php +++ b/test/integration/DependencyResolver/ResolveDependencyWithComposerTest.php @@ -94,8 +94,8 @@ public function testDependenciesAreResolvedToExpectedVersions( false, ); - self::assertSame($expectedVersion, $package->version()); - self::assertNotNull($package->downloadUrl()); - self::assertStringMatchesFormat($expectedDownloadUrl, $package->downloadUrl()); + self::assertSame($expectedVersion, $package->piePackage->version()); + self::assertNotNull($package->piePackage->downloadUrl()); + self::assertStringMatchesFormat($expectedDownloadUrl, $package->piePackage->downloadUrl()); } } diff --git a/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php b/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php index 114f66cc..5522eee5 100644 --- a/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php +++ b/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php @@ -97,8 +97,8 @@ public function testPackageThatCanBeResolved(): void $this->createMock(QuieterConsoleIO::class), ))($this->composer, $targetPlatform, new RequestedPackageAndVersion('asgrim/example-pie-extension', '^1.0'), false); - self::assertSame('asgrim/example-pie-extension', $package->name()); - self::assertStringStartsWith('1.', $package->version()); + self::assertSame('asgrim/example-pie-extension', $package->piePackage->name()); + self::assertStringStartsWith('1.', $package->piePackage->version()); } /** @return array, 1: non-empty-string, 2: non-empty-string}> */ @@ -190,8 +190,8 @@ public function testUnresolvedPackageCanBeInstalledWithForceOption(array $platfo true, ); - self::assertSame('asgrim/example-pie-extension', $package->name()); - self::assertStringStartsWith('1.', $package->version()); + self::assertSame('asgrim/example-pie-extension', $package->piePackage->name()); + self::assertStringStartsWith('1.', $package->piePackage->version()); } public function testZtsOnlyPackageCannotBeInstalledOnNtsSystem(): void @@ -423,8 +423,8 @@ public function testPackageThatCanBeResolvedWithReplaceConflict(): void $this->createMock(QuieterConsoleIO::class), ))($this->composer, $targetPlatform, new RequestedPackageAndVersion('asgrim/example-pie-extension', '^1.0'), false); - self::assertSame('asgrim/example-pie-extension', $package->name()); - self::assertStringStartsWith('1.', $package->version()); + self::assertSame('asgrim/example-pie-extension', $package->piePackage->name()); + self::assertStringStartsWith('1.', $package->piePackage->version()); } public function testBundledExtensionCannotBeInstalledOnDevPhpVersion(): void @@ -539,6 +539,6 @@ public function testBundledExtensionWillInstallOnBuildProviderWithForce(string $ $package = $resolver->__invoke($this->composer, $targetPlatform, $requestedPackage, true); - self::assertSame('php/bundled', $package->name()); + self::assertSame('php/bundled', $package->piePackage->name()); } } From 07adb2aaeb26ea8ae5864e4df3be7f9b64453a50 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 24 Jun 2026 08:25:57 +0100 Subject: [PATCH 15/22] 466: fix usage of PHP project installer now accepting a list of pacakges --- phpstan-baseline.neon | 6 ------ .../InstallSelectedPackageTest.php | 16 +++++++--------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 5ec0f102..fb827ed1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -36,12 +36,6 @@ parameters: count: 1 path: src/Command/SelfVerifyCommand.php - - - message: '#^Cannot cast mixed to string\.$#' - identifier: cast.string - count: 1 - path: src/Command/UninstallCommand.php - - message: '#^Call to function assert\(\) with true will always evaluate to true\.$#' identifier: function.alreadyNarrowedType diff --git a/test/unit/Installing/InstallForPhpProject/InstallSelectedPackageTest.php b/test/unit/Installing/InstallForPhpProject/InstallSelectedPackageTest.php index 1d74a7f1..910271a7 100644 --- a/test/unit/Installing/InstallForPhpProject/InstallSelectedPackageTest.php +++ b/test/unit/Installing/InstallForPhpProject/InstallSelectedPackageTest.php @@ -6,9 +6,7 @@ use Php\Pie\Command\InvokeSubCommand; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; -use Php\Pie\ExtensionName; use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage; -use Php\Pie\Util\OutputFormatterWithPrefix; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command; @@ -28,20 +26,20 @@ public function testSubCommandIsInvoked(): void $command, [ 'command' => 'install', - 'requested-package-and-version' => 'foo/foo:^1.0', + 'requested-package-and-version' => ['foo/foo:^1.0'], ], $input, - self::isInstanceOf(OutputFormatterWithPrefix::class), ) ->willReturn(0); $installer = new InstallSelectedPackage($invoker); $installer->withSubCommand( - ExtensionName::normaliseFromString('foo'), - new RequestedPackageAndVersion( - 'foo/foo', - '^1.0', - ), + [ + new RequestedPackageAndVersion( + 'foo/foo', + '^1.0', + ), + ], $command, $input, ); From 719238f92712b0c854a7a5a4cfea9588baaf7c38 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 24 Jun 2026 08:33:58 +0100 Subject: [PATCH 16/22] 466: fix remaining unit test issues --- .../PrescanSystemDependenciesTest.php | 13 +++++++------ .../InstallPiePackageFromPathTest.php | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php b/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php index 8f292c0e..2fb1c747 100644 --- a/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php +++ b/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php @@ -16,6 +16,7 @@ use Php\Pie\DependencyResolver\FetchDependencyStatuses; use Php\Pie\DependencyResolver\Package; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; +use Php\Pie\DependencyResolver\ResolvedPackageRequest; use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPlatform; use PHPUnit\Framework\Attributes\CoversClass; @@ -77,7 +78,7 @@ public function testAllDependenciesSatisfied(): void $this->dependencyResolver->expects(self::once()) ->method('__invoke') ->with($this->composer, $this->targetPlatform, $request, true) - ->willReturn($piePackage); + ->willReturn(new ResolvedPackageRequest($piePackage, $request)); $versionParser = new VersionParser(); @@ -113,7 +114,7 @@ public function testMissingDependencyThatDoesNotHaveAnyPackageManagerDefinition( $this->dependencyResolver->expects(self::once()) ->method('__invoke') ->with($this->composer, $this->targetPlatform, $request, true) - ->willReturn($piePackage); + ->willReturn(new ResolvedPackageRequest($piePackage, $request)); $versionParser = new VersionParser(); @@ -153,7 +154,7 @@ public function testMissingDependencyThatDoesNotHaveMyPackageManagerDefinition() $this->dependencyResolver->expects(self::once()) ->method('__invoke') ->with($this->composer, $this->targetPlatform, $request, true) - ->willReturn($piePackage); + ->willReturn(new ResolvedPackageRequest($piePackage, $request)); $versionParser = new VersionParser(); @@ -193,7 +194,7 @@ public function testMissingDependenciesFailToInstall(): void $this->dependencyResolver->expects(self::once()) ->method('__invoke') ->with($this->composer, $this->targetPlatform, $request, true) - ->willReturn($piePackage); + ->willReturn(new ResolvedPackageRequest($piePackage, $request)); $versionParser = new VersionParser(); @@ -233,7 +234,7 @@ public function testMissingDependenciesAreSuccessfullyInstalled(): void $this->dependencyResolver->expects(self::once()) ->method('__invoke') ->with($this->composer, $this->targetPlatform, $request, true) - ->willReturn($piePackage); + ->willReturn(new ResolvedPackageRequest($piePackage, $request)); $versionParser = new VersionParser(); @@ -274,7 +275,7 @@ public function testMissingDependenciesAreNotInstalledWhenShouldNotAutoInstallAn $this->dependencyResolver->expects(self::once()) ->method('__invoke') ->with($this->composer, $this->targetPlatform, $request, true) - ->willReturn($piePackage); + ->willReturn(new ResolvedPackageRequest($piePackage, $request)); $versionParser = new VersionParser(); diff --git a/test/unit/Installing/InstallForPhpProject/InstallPiePackageFromPathTest.php b/test/unit/Installing/InstallForPhpProject/InstallPiePackageFromPathTest.php index 20df724f..59233566 100644 --- a/test/unit/Installing/InstallForPhpProject/InstallPiePackageFromPathTest.php +++ b/test/unit/Installing/InstallForPhpProject/InstallPiePackageFromPathTest.php @@ -59,7 +59,7 @@ public function testInvokeWithSuccessfulSubCommand(): void $this->command, [ 'command' => 'install', - 'requested-package-and-version' => 'foo/bar:*@dev', + 'requested-package-and-version' => ['foo/bar:*@dev'], ], $this->input, ) @@ -101,7 +101,7 @@ public function testInvokeWithSubCommandException(): void $this->command, [ 'command' => 'install', - 'requested-package-and-version' => 'foo/bar:*@dev', + 'requested-package-and-version' => ['foo/bar:*@dev'], ], $this->input, ) From 8648586de507a52a33e09b87f41002b4b05ae9e1 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 24 Jun 2026 09:00:47 +0100 Subject: [PATCH 17/22] 466: fixing up integration tests --- test/integration/Command/BuildCommandTest.php | 2 +- test/integration/Command/DownloadCommandTest.php | 12 ++++++------ test/integration/Command/InfoCommandTest.php | 2 +- test/integration/Command/InstallCommandTest.php | 4 ++-- .../InstallExtensionsForProjectCommandTest.php | 14 ++++++++------ 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/test/integration/Command/BuildCommandTest.php b/test/integration/Command/BuildCommandTest.php index 6db411a1..eaf71aa1 100644 --- a/test/integration/Command/BuildCommandTest.php +++ b/test/integration/Command/BuildCommandTest.php @@ -27,7 +27,7 @@ public function setUp(): void public function testBuildCommandWillBuildTheExtension(): void { - $this->commandTester->execute(['requested-package-and-version' => self::TEST_PACKAGE]); + $this->commandTester->execute(['requested-package-and-version' => [self::TEST_PACKAGE]]); $this->commandTester->assertCommandIsSuccessful(); diff --git a/test/integration/Command/DownloadCommandTest.php b/test/integration/Command/DownloadCommandTest.php index 7ce923c6..62e29360 100644 --- a/test/integration/Command/DownloadCommandTest.php +++ b/test/integration/Command/DownloadCommandTest.php @@ -68,7 +68,7 @@ public function testDownloadCommandWillDownloadCompatibleExtension( string $requestedVersion, string $expectedVersion, ): void { - $this->commandTester->execute(['requested-package-and-version' => $requestedVersion]); + $this->commandTester->execute(['requested-package-and-version' => [$requestedVersion]]); $this->commandTester->assertCommandIsSuccessful(); @@ -83,7 +83,7 @@ public function testDownloadCommandWillDownloadSpecificCommits(): void self::markTestSkipped('This test can only run on non-Windows systems'); } - $this->commandTester->execute(['requested-package-and-version' => 'asgrim/example-pie-extension:dev-main#9b5e6c80a1e05556e4e6824f0c112a4992cee001']); + $this->commandTester->execute(['requested-package-and-version' => ['asgrim/example-pie-extension:dev-main#9b5e6c80a1e05556e4e6824f0c112a4992cee001']]); $this->commandTester->assertCommandIsSuccessful(); @@ -110,7 +110,7 @@ public function testDownloadingWithPhpConfig(string $requestedVersion, string $e $this->commandTester->execute([ '--with-php-config' => $phpConfigPath, - 'requested-package-and-version' => $requestedVersion, + 'requested-package-and-version' => [$requestedVersion], ]); $this->commandTester->assertCommandIsSuccessful(); @@ -132,7 +132,7 @@ public function testDownloadingWithPhpPath(string $requestedVersion, string $exp $this->commandTester->execute([ '--with-php-path' => $phpBinaryPath, - 'requested-package-and-version' => $requestedVersion, + 'requested-package-and-version' => [$requestedVersion], ]); $this->commandTester->assertCommandIsSuccessful(); @@ -146,7 +146,7 @@ public function testDownloadingWithPhpPath(string $requestedVersion, string $exp public function testDownloadCommandFailsWhenUsingIncompatiblePhpVersion(): void { // 1.0.0 is only compatible with PHP 8.3.0 - self::assertSame(1, $this->commandTester->execute(['requested-package-and-version' => self::TEST_PACKAGE . ':1.0.0'])); + self::assertSame(1, $this->commandTester->execute(['requested-package-and-version' => [self::TEST_PACKAGE . ':1.0.0']])); $output = $this->commandTester->getDisplay(); self::assertStringContainsString( @@ -164,7 +164,7 @@ public function testDownloadCommandPassesWhenUsingIncompatiblePhpVersionWithForc $this->commandTester->execute( [ - 'requested-package-and-version' => $incompatiblePackage, + 'requested-package-and-version' => [$incompatiblePackage], '--force' => true, ], ); diff --git a/test/integration/Command/InfoCommandTest.php b/test/integration/Command/InfoCommandTest.php index 5871f1f3..8df309ba 100644 --- a/test/integration/Command/InfoCommandTest.php +++ b/test/integration/Command/InfoCommandTest.php @@ -22,7 +22,7 @@ public function setUp(): void public function testInfoCommandDisplaysInformation(): void { - $this->commandTester->execute(['requested-package-and-version' => 'asgrim/example-pie-extension:dev-main#9b5e6c80a1e05556e4e6824f0c112a4992cee001']); + $this->commandTester->execute(['requested-package-and-version' => ['asgrim/example-pie-extension:dev-main#9b5e6c80a1e05556e4e6824f0c112a4992cee001']]); $this->commandTester->assertCommandIsSuccessful(); diff --git a/test/integration/Command/InstallCommandTest.php b/test/integration/Command/InstallCommandTest.php index 631325bc..62921439 100644 --- a/test/integration/Command/InstallCommandTest.php +++ b/test/integration/Command/InstallCommandTest.php @@ -90,7 +90,7 @@ public function testInstallCommandWillInstallCompatibleExtensionNonWindows(strin $this->commandTester->execute( [ - 'requested-package-and-version' => self::TEST_PACKAGE, + 'requested-package-and-version' => [self::TEST_PACKAGE], '--with-php-config' => $phpConfigPath, '--skip-enable-extension' => true, ], @@ -117,7 +117,7 @@ public function testInstallCommandWillInstallCompatibleExtensionNonWindows(strin public function testInstallCommandWillInstallCompatibleExtensionWindows(): void { $this->commandTester->execute([ - 'requested-package-and-version' => self::TEST_PACKAGE, + 'requested-package-and-version' => [self::TEST_PACKAGE], '--skip-enable-extension' => true, ]); diff --git a/test/integration/Command/InstallExtensionsForProjectCommandTest.php b/test/integration/Command/InstallExtensionsForProjectCommandTest.php index b13e47e1..8376dc9b 100644 --- a/test/integration/Command/InstallExtensionsForProjectCommandTest.php +++ b/test/integration/Command/InstallExtensionsForProjectCommandTest.php @@ -18,7 +18,6 @@ use Php\Pie\ComposerIntegration\QuieterConsoleIO; use Php\Pie\Container; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; -use Php\Pie\ExtensionName; use Php\Pie\ExtensionType; use Php\Pie\Installing\InstallForPhpProject\CheckExtensionStatus; use Php\Pie\Installing\InstallForPhpProject\ComposerFactoryForProject; @@ -131,11 +130,14 @@ public function testInstallingExtensionsForPhpProject(): void $this->installSelectedPackage->expects(self::once()) ->method('withSubCommand') ->with( - ExtensionName::normaliseFromString('foobar'), - new RequestedPackageAndVersion( - 'vendor1/foobar', - '^1.2', - ), + [ + new RequestedPackageAndVersion( + 'vendor1/foobar', + '^1.2', + ), + ], + self::isInstanceOf(Command::class), + self::isInstanceOf(InputInterface::class), ) ->willReturn(0); From 04d2dcf7d05ba77834b88e1ddca950f5b7cfd203 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 24 Jun 2026 10:06:09 +0100 Subject: [PATCH 18/22] 466: handle configure flags properly for multiple packages --- src/Command/BuildCommand.php | 11 ++-- src/Command/CommandHelper.php | 64 ++++++++++++------- src/Command/InstallCommand.php | 11 ++-- .../AddInstalledJsonMetadata.php | 2 +- .../InstallAndBuildProcess.php | 2 +- .../OverrideDownloadUrlInstallListener.php | 2 +- .../PieComposerRequest.php | 10 ++- test/unit/Command/CommandHelperTest.php | 8 ++- .../AddInstalledJsonMetadataTest.php | 4 +- .../InstallAndBuildProcessTest.php | 6 +- ...OverrideDownloadUrlInstallListenerTest.php | 2 +- 11 files changed, 76 insertions(+), 46 deletions(-) diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index 6b82eb71..c47a4e0c 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -14,6 +14,7 @@ use Php\Pie\DependencyResolver\DependencyInstaller\PrescanSystemDependencies; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\InvalidPackageName; +use Php\Pie\DependencyResolver\ResolvedPackageRequest; use Php\Pie\DependencyResolver\UnableToResolveRequirement; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Platform\PackageManager; @@ -25,6 +26,8 @@ use Symfony\Component\Console\Output\OutputInterface; use Throwable; +use function array_map; + #[AsCommand( name: 'build', description: 'Download and build a PIE-compatible PHP extension, without installing it.', @@ -131,11 +134,11 @@ public function execute(InputInterface $input, OutputInterface $output): int return self::INVALID; } - // Now we know what package we have, we can validate the configure options for the command and re-create the + // Now we know what packages we have, we can validate the configure options for the command and re-create the // Composer instance with the populated configure options - // @todo handle this; CommandHelper::bindConfigureOptionsFromPackage($this, $package, $input); - // @todo handle this; $configureOptionsValues = CommandHelper::processConfigureOptionsFromInput($package, $input); - $configureOptionsValues = []; // @todo handle this + $resolvedPiePackages = array_map(static fn (ResolvedPackageRequest $resolvedPackage) => $resolvedPackage->piePackage, $resolvedPackages); + CommandHelper::bindConfigureOptionsFromPackage($this, $resolvedPiePackages, $input); + $configureOptionsValues = CommandHelper::processConfigureOptionsFromInput($resolvedPiePackages, $input); $composer = PieComposerFactory::createPieComposer( $this->container, diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index c46292c0..fb6c9d7a 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -390,45 +390,61 @@ static function (RequestedPackageAndVersion $requestedNameAndVersion) use ($depe ); } - public static function bindConfigureOptionsFromPackage(Command $command, Package $package, InputInterface $input): void + /** @param non-empty-list $packages */ + public static function bindConfigureOptionsFromPackage(Command $command, array $packages, InputInterface $input): void { - foreach ($package->configureOptions() as $configureOption) { - $command->addOption( - $configureOption->name, - null, - $configureOption->needsValue ? InputOption::VALUE_REQUIRED : InputOption::VALUE_NONE, - $configureOption->description, - ); + foreach ($packages as $package) { + foreach ($package->configureOptions() as $configureOption) { + if ($command->getDefinition()->hasOption($configureOption->name)) { + continue; + } + + $command->addOption( + $configureOption->name, + null, + $configureOption->needsValue ? InputOption::VALUE_REQUIRED : InputOption::VALUE_NONE, + $configureOption->description, + ); + } } self::validateInput($input, $command); } - /** @return list */ - public static function processConfigureOptionsFromInput(Package $package, InputInterface $input): array + /** + * @param non-empty-list $packages + * + * @return array> Keyed by package name + */ + public static function processConfigureOptionsFromInput(array $packages, InputInterface $input): array { $configureOptionsValues = []; - foreach ($package->configureOptions() as $configureOption) { - if (! $input->hasOption($configureOption->name)) { - continue; - } + foreach ($packages as $package) { + $optionsForPackage = []; + foreach ($package->configureOptions() as $configureOption) { + if (! $input->hasOption($configureOption->name)) { + continue; + } - $value = $input->getOption($configureOption->name); + $value = $input->getOption($configureOption->name); - if ($configureOption->needsValue) { - if (is_string($value) && $value !== '') { - $configureOptionsValues[] = '--' . $configureOption->name . '=' . $value; + if ($configureOption->needsValue) { + if (is_string($value) && $value !== '') { + $optionsForPackage[] = '--' . $configureOption->name . '=' . $value; + } + + continue; } - continue; - } + Assert::boolean($value); + if ($value !== true) { + continue; + } - Assert::boolean($value); - if ($value !== true) { - continue; + $optionsForPackage[] = '--' . $configureOption->name; } - $configureOptionsValues[] = '--' . $configureOption->name; + $configureOptionsValues[$package->name()] = $optionsForPackage; } return $configureOptionsValues; diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 21a09eae..fc712ab8 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -14,6 +14,7 @@ use Php\Pie\DependencyResolver\DependencyInstaller\PrescanSystemDependencies; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\InvalidPackageName; +use Php\Pie\DependencyResolver\ResolvedPackageRequest; use Php\Pie\DependencyResolver\UnableToResolveRequirement; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; use Php\Pie\Platform\PackageManager; @@ -26,6 +27,8 @@ use Symfony\Component\Console\Output\OutputInterface; use Throwable; +use function array_map; + #[AsCommand( name: 'install', description: 'Download, build, and install a PIE-compatible PHP extension.', @@ -145,11 +148,11 @@ public function execute(InputInterface $input, OutputInterface $output): int return self::INVALID; } - // Now we know what package we have, we can validate the configure options for the command and re-create the + // Now we know what packages we have, we can validate the configure options for the command and re-create the // Composer instance with the populated configure options - // @todo handle this; CommandHelper::bindConfigureOptionsFromPackage($this, $package, $input); - // @todo handle this; $configureOptionsValues = CommandHelper::processConfigureOptionsFromInput($package, $input); - $configureOptionsValues = []; // @todo handle this + $resolvedPiePackages = array_map(static fn (ResolvedPackageRequest $resolvedPackage) => $resolvedPackage->piePackage, $resolvedPackages); + CommandHelper::bindConfigureOptionsFromPackage($this, $resolvedPiePackages, $input); + $configureOptionsValues = CommandHelper::processConfigureOptionsFromInput($resolvedPiePackages, $input); $composer = PieComposerFactory::createPieComposer( $this->container, diff --git a/src/ComposerIntegration/AddInstalledJsonMetadata.php b/src/ComposerIntegration/AddInstalledJsonMetadata.php index b829d2e0..d7907753 100644 --- a/src/ComposerIntegration/AddInstalledJsonMetadata.php +++ b/src/ComposerIntegration/AddInstalledJsonMetadata.php @@ -69,7 +69,7 @@ public function addBuildMetadata( $composer, $composerPackage, InstalledJsonMetadata::KEY_CONFIGURE_OPTIONS, - implode(' ', $composerRequest->configureOptions), + implode(' ', $composerRequest->configureOptionsFor($composerPackage->getName())), ); $this->addPieMetadata( diff --git a/src/ComposerIntegration/InstallAndBuildProcess.php b/src/ComposerIntegration/InstallAndBuildProcess.php index 45a97ab3..619b524d 100644 --- a/src/ComposerIntegration/InstallAndBuildProcess.php +++ b/src/ComposerIntegration/InstallAndBuildProcess.php @@ -61,7 +61,7 @@ public function __invoke( $builtBinaryFile = ($this->pieBuild)( $downloadedPackage, $composerRequest->targetPlatform, - $composerRequest->configureOptions, + $composerRequest->configureOptionsFor($downloadedPackage->package->name()), $io, ); diff --git a/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php b/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php index c65a204b..8ad19d12 100644 --- a/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php +++ b/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php @@ -80,7 +80,7 @@ function (OperationInterface $operation): void { foreach ($downloadUrlMethods as $downloadUrlMethod) { $this->io->write('Trying to download using: ' . $downloadUrlMethod->value, verbosity: IOInterface::VERY_VERBOSE); - if ($downloadUrlMethod === DownloadUrlMethod::PrePackagedBinary && $this->composerRequest->configureOptions !== []) { + if ($downloadUrlMethod === DownloadUrlMethod::PrePackagedBinary && $this->composerRequest->configureOptionsFor($composerPackage->getName()) !== []) { $configureOptionsConflictMessage = 'Cannot use pre-packaged-binary download method, as configure options were passed.'; $downloadMethodFailures[$downloadUrlMethod->value] = $configureOptionsConflictMessage; diff --git a/src/ComposerIntegration/PieComposerRequest.php b/src/ComposerIntegration/PieComposerRequest.php index b44978e5..5c137853 100644 --- a/src/ComposerIntegration/PieComposerRequest.php +++ b/src/ComposerIntegration/PieComposerRequest.php @@ -22,8 +22,8 @@ final class PieComposerRequest private readonly array $requestedPackageNames; /** - * @param list $requestedPackages - * @param list $configureOptions + * @param list $requestedPackages + * @param array> $configureOptions Keyed by package name */ public function __construct( public readonly IOInterface $pieOutput, @@ -36,6 +36,12 @@ public function __construct( $this->requestedPackageNames = array_map(static fn (RequestedPackageAndVersion $request) => $request->package, $this->requestedPackages); } + /** @return list */ + public function configureOptionsFor(string $packageName): array + { + return $this->configureOptions[$packageName] ?? []; + } + /** * Useful for when we don't want to perform any "write" style operations; * for example just reading metadata about the installed system. diff --git a/test/unit/Command/CommandHelperTest.php b/test/unit/Command/CommandHelperTest.php index e67be1bd..fbd5d87d 100644 --- a/test/unit/Command/CommandHelperTest.php +++ b/test/unit/Command/CommandHelperTest.php @@ -248,12 +248,14 @@ public function testProcessingConfigureOptionsFromInput(): void $input = new ArrayInput(['--with-stuff' => 'lolz', '--enable-thing' => true], $inputDefinition); - $options = CommandHelper::processConfigureOptionsFromInput($package, $input); + $options = CommandHelper::processConfigureOptionsFromInput([$package], $input); self::assertSame( [ - '--with-stuff=lolz', - '--enable-thing', + 'foo/bar' => [ + '--with-stuff=lolz', + '--enable-thing', + ], ], $options, ); diff --git a/test/unit/ComposerIntegration/AddInstalledJsonMetadataTest.php b/test/unit/ComposerIntegration/AddInstalledJsonMetadataTest.php index e9f7684c..26485177 100644 --- a/test/unit/ComposerIntegration/AddInstalledJsonMetadataTest.php +++ b/test/unit/ComposerIntegration/AddInstalledJsonMetadataTest.php @@ -64,7 +64,7 @@ public function testMetadataForDownloads(): void ), [new RequestedPackageAndVersion('foo/bar', '^1.0')], PieOperation::Build, - ['--foo', '--bar="yes"'], + ['foo/bar' => ['--foo', '--bar="yes"']], false, ), clone $package, @@ -103,7 +103,7 @@ public function testMetadataForBuilds(): void ), [new RequestedPackageAndVersion('foo/bar', '^1.0')], PieOperation::Build, - ['--foo', '--bar="yes"'], + ['foo/bar' => ['--foo', '--bar="yes"']], false, ), clone $package, diff --git a/test/unit/ComposerIntegration/InstallAndBuildProcessTest.php b/test/unit/ComposerIntegration/InstallAndBuildProcessTest.php index e8f913d7..fc4d8378 100644 --- a/test/unit/ComposerIntegration/InstallAndBuildProcessTest.php +++ b/test/unit/ComposerIntegration/InstallAndBuildProcessTest.php @@ -68,7 +68,7 @@ public function testDownloadWithoutBuildAndInstall(): void ), [new RequestedPackageAndVersion('foo/bar', '^1.0')], PieOperation::Download, - ['--foo', '--bar="yes"'], + ['foo/bar' => ['--foo', '--bar="yes"']], false, ); $composerPackage = new CompletePackage('foo/bar', '1.2.3.0', '1.2.3'); @@ -109,7 +109,7 @@ public function testDownloadAndBuildWithoutInstall(): void ), [new RequestedPackageAndVersion('foo/bar', '^1.0')], PieOperation::Build, - ['--foo', '--bar="yes"'], + ['foo/bar' => ['--foo', '--bar="yes"']], false, ); $composerPackage = new CompletePackage('foo/bar', '1.2.3.0', '1.2.3'); @@ -153,7 +153,7 @@ public function testDownloadBuildAndInstall(): void ), [new RequestedPackageAndVersion('foo/bar', '^1.0')], PieOperation::Install, - ['--foo', '--bar="yes"'], + ['foo/bar' => ['--foo', '--bar="yes"']], false, ); $composerPackage = new CompletePackage('foo/bar', '1.2.3.0', '1.2.3'); diff --git a/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php b/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php index 6ec330e6..e2e64fa8 100644 --- a/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php +++ b/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php @@ -541,7 +541,7 @@ public function testPrePackagedBinaryMethodIsIgnoredWhenConfigureOptionsArePasse ), [new RequestedPackageAndVersion('foo/bar', '^1.1')], PieOperation::Install, - ['--with-foo'], + ['foo/bar' => ['--with-foo']], false, ), ); From 1429c6531094f171a906d94d5485db8a411086ee Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 24 Jun 2026 10:34:23 +0100 Subject: [PATCH 19/22] 466: prevent test from outputting using NullOutput --- src/Container.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Container.php b/src/Container.php index 7dfb663a..0e79508d 100644 --- a/src/Container.php +++ b/src/Container.php @@ -49,6 +49,7 @@ use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -239,6 +240,21 @@ public static function testFactory(): ContainerInterface return self::$testBuffer; }); + // QuieterConsoleIO is wired separately from IOInterface in self::factory(), and writes + // directly to a real ConsoleOutput; override it here only, so tests don't leak its output + // to the terminal. + $container->singleton(QuieterConsoleIO::class, static function (ContainerInterface $container): QuieterConsoleIO { + return new QuieterConsoleIO( + $container->get(InputInterface::class), + new NullOutput(), + new MinimalHelperSet( + [ + 'question' => new QuestionHelper(), + ], + ), + ); + }); + return $container; } From 38a4eebc6192d08212965dadded8de5b88d8c7bc Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 24 Jun 2026 11:53:53 +0100 Subject: [PATCH 20/22] 466: handle edge case of collisions of configure options --- src/Command/CommandHelper.php | 19 ++++++++++--- src/Command/ConfigureOptionCollision.php | 24 +++++++++++++++++ test/unit/Command/CommandHelperTest.php | 34 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 src/Command/ConfigureOptionCollision.php diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index fb6c9d7a..305c9d40 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -390,15 +390,28 @@ static function (RequestedPackageAndVersion $requestedNameAndVersion) use ($depe ); } - /** @param non-empty-list $packages */ + /** + * @param non-empty-list $packages + * + * @throws ConfigureOptionCollision if two of the requested packages declare a configure option with the same name. + */ public static function bindConfigureOptionsFromPackage(Command $command, array $packages, InputInterface $input): void { + /** @var array $optionOwners */ + $optionOwners = []; + foreach ($packages as $package) { foreach ($package->configureOptions() as $configureOption) { - if ($command->getDefinition()->hasOption($configureOption->name)) { - continue; + if (array_key_exists($configureOption->name, $optionOwners)) { + throw ConfigureOptionCollision::forOptionName( + $configureOption->name, + $optionOwners[$configureOption->name], + $package, + ); } + $optionOwners[$configureOption->name] = $package; + $command->addOption( $configureOption->name, null, diff --git a/src/Command/ConfigureOptionCollision.php b/src/Command/ConfigureOptionCollision.php new file mode 100644 index 00000000..b42839e7 --- /dev/null +++ b/src/Command/ConfigureOptionCollision.php @@ -0,0 +1,24 @@ +name(), + $secondPackage->name(), + $optionName, + )); + } +} diff --git a/test/unit/Command/CommandHelperTest.php b/test/unit/Command/CommandHelperTest.php index fbd5d87d..084661d8 100644 --- a/test/unit/Command/CommandHelperTest.php +++ b/test/unit/Command/CommandHelperTest.php @@ -16,6 +16,7 @@ use Composer\Util\Platform; use InvalidArgumentException; use Php\Pie\Command\CommandHelper; +use Php\Pie\Command\ConfigureOptionCollision; use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\Package; @@ -261,6 +262,39 @@ public function testProcessingConfigureOptionsFromInput(): void ); } + public function testBindConfigureOptionsFromPackageThrowsWhenTwoPackagesDeclareSameOptionName(): void + { + $composerPackageA = $this->createMock(CompletePackageInterface::class); + $composerPackageA->method('getPrettyName')->willReturn('foo/bar'); + $composerPackageA->method('getPrettyVersion')->willReturn('1.0.0'); + $composerPackageA->method('getType')->willReturn('php-ext'); + $composerPackageA->method('getPhpExt')->willReturn([ + 'configure-options' => [ + ['name' => 'with-stuff', 'needs-value' => true], + ], + ]); + $packageA = Package::fromComposerCompletePackage($composerPackageA); + + $composerPackageB = $this->createMock(CompletePackageInterface::class); + $composerPackageB->method('getPrettyName')->willReturn('baz/qux'); + $composerPackageB->method('getPrettyVersion')->willReturn('2.0.0'); + $composerPackageB->method('getType')->willReturn('php-ext'); + $composerPackageB->method('getPhpExt')->willReturn([ + 'configure-options' => [ + ['name' => 'with-stuff'], + ], + ]); + $packageB = Package::fromComposerCompletePackage($composerPackageB); + + $command = new Command(); + $input = new ArrayInput([]); + + $this->expectException(ConfigureOptionCollision::class); + $this->expectExceptionMessage('Both foo/bar and baz/qux declare a configure option named --with-stuff'); + + CommandHelper::bindConfigureOptionsFromPackage($command, [$packageA, $packageB], $input); + } + #[RequiresOperatingSystemFamily('Windows')] public function testWindowsMachinesCannotUseWithPhpConfigOption(): void { From 868e4d17a3fb84eb9f9a253963730d29aa5ca95c Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 24 Jun 2026 12:07:58 +0100 Subject: [PATCH 21/22] 466: only support a single ext for pie info --- src/Command/CommandHelper.php | 4 ++++ src/Command/InfoCommand.php | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 305c9d40..c16849dc 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -321,6 +321,10 @@ public static function requestedNameAndVersionPairs(InputInterface $input): arra { $requestedPackageStrings = $input->getArgument(self::ARG_REQUESTED_PACKAGE_AND_VERSION); + if (is_string($requestedPackageStrings)) { + $requestedPackageStrings = [$requestedPackageStrings]; + } + if (! is_array($requestedPackageStrings) || ! count($requestedPackageStrings)) { throw new InvalidArgumentException('No package was requested for installation'); } diff --git a/src/Command/InfoCommand.php b/src/Command/InfoCommand.php index 28d05a5b..fc30a98e 100644 --- a/src/Command/InfoCommand.php +++ b/src/Command/InfoCommand.php @@ -19,6 +19,7 @@ use Psr\Container\ContainerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -46,7 +47,13 @@ public function configure(): void { parent::configure(); - CommandHelper::configureDownloadBuildInstallOptions($this); + // `info` only ever supports a single package, unlike the other download/build/install commands. + CommandHelper::configureDownloadBuildInstallOptions($this, false); + $this->addArgument( + CommandHelper::ARG_REQUESTED_PACKAGE_AND_VERSION, + InputArgument::REQUIRED, + 'The PIE package name and version constraint to use, in the format {vendor/package}{?:{?version-constraint}{?@stability}}, for example `xdebug/xdebug:^3.4@alpha`, `xdebug/xdebug:@alpha`, `xdebug/xdebug:^3.4`, etc.', + ); } public function execute(InputInterface $input, OutputInterface $output): int From dddf53f1a6959f8538175423590e3f845c0d40b3 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 24 Jun 2026 12:38:21 +0100 Subject: [PATCH 22/22] 466: extract array_maps to reduce repetition --- src/Command/BuildCommand.php | 4 +- src/Command/InstallCommand.php | 4 +- .../ComposerIntegrationHandler.php | 20 ++---- .../ResolvedPackageRequest.php | 43 ++++++++++++ .../ResolvedPackageRequestTest.php | 70 +++++++++++++++++++ 5 files changed, 119 insertions(+), 22 deletions(-) create mode 100644 test/unit/DependencyResolver/ResolvedPackageRequestTest.php diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index c47a4e0c..278b6936 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -26,8 +26,6 @@ use Symfony\Component\Console\Output\OutputInterface; use Throwable; -use function array_map; - #[AsCommand( name: 'build', description: 'Download and build a PIE-compatible PHP extension, without installing it.', @@ -136,7 +134,7 @@ public function execute(InputInterface $input, OutputInterface $output): int // Now we know what packages we have, we can validate the configure options for the command and re-create the // Composer instance with the populated configure options - $resolvedPiePackages = array_map(static fn (ResolvedPackageRequest $resolvedPackage) => $resolvedPackage->piePackage, $resolvedPackages); + $resolvedPiePackages = ResolvedPackageRequest::piePackages($resolvedPackages); CommandHelper::bindConfigureOptionsFromPackage($this, $resolvedPiePackages, $input); $configureOptionsValues = CommandHelper::processConfigureOptionsFromInput($resolvedPiePackages, $input); diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index fc712ab8..618d0114 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -27,8 +27,6 @@ use Symfony\Component\Console\Output\OutputInterface; use Throwable; -use function array_map; - #[AsCommand( name: 'install', description: 'Download, build, and install a PIE-compatible PHP extension.', @@ -150,7 +148,7 @@ public function execute(InputInterface $input, OutputInterface $output): int // Now we know what packages we have, we can validate the configure options for the command and re-create the // Composer instance with the populated configure options - $resolvedPiePackages = array_map(static fn (ResolvedPackageRequest $resolvedPackage) => $resolvedPackage->piePackage, $resolvedPackages); + $resolvedPiePackages = ResolvedPackageRequest::piePackages($resolvedPackages); CommandHelper::bindConfigureOptionsFromPackage($this, $resolvedPiePackages, $input); $configureOptionsValues = CommandHelper::processConfigureOptionsFromInput($resolvedPiePackages, $input); diff --git a/src/ComposerIntegration/ComposerIntegrationHandler.php b/src/ComposerIntegration/ComposerIntegrationHandler.php index 306f6f1f..82009239 100644 --- a/src/ComposerIntegration/ComposerIntegrationHandler.php +++ b/src/ComposerIntegration/ComposerIntegrationHandler.php @@ -149,10 +149,7 @@ public function runInstall( $composerInstaller = PieComposerInstaller::createWithPhpBinary( $targetPlatform->phpBinaryPath, - array_map( - static fn (ResolvedPackageRequest $resolvedPackageRequest) => $resolvedPackageRequest->piePackage->extensionName(), - $resolvedRequestedPackages, - ), + ResolvedPackageRequest::extensionNames($resolvedRequestedPackages), $this->arrayCollectionIo, $composer, ); @@ -166,10 +163,7 @@ public function runInstall( if (file_exists(PieComposerFactory::getLockFile($pieComposerJson))) { $composerInstaller->setUpdate(true); - $composerInstaller->setUpdateAllowList(array_map( - static fn (ResolvedPackageRequest $resolvedPackageRequest) => $resolvedPackageRequest->requestedPackageAndVersion->package, - $resolvedRequestedPackages, - )); + $composerInstaller->setUpdateAllowList(ResolvedPackageRequest::requestedPackageNames($resolvedRequestedPackages)); } $resultCode = $composerInstaller->run(); @@ -208,10 +202,7 @@ public function runUninstall( $composerInstaller = PieComposerInstaller::createWithPhpBinary( $targetPlatform->phpBinaryPath, - array_map( - static fn (ResolvedPackageRequest $resolvedPackageRequest) => $resolvedPackageRequest->piePackage->extensionName(), - $resolvedPackagesToRemove, - ), + ResolvedPackageRequest::extensionNames($resolvedPackagesToRemove), $this->arrayCollectionIo, $composer, ); @@ -224,10 +215,7 @@ public function runUninstall( if (file_exists(PieComposerFactory::getLockFile($pieComposerJson))) { $composerInstaller->setUpdate(true); - $composerInstaller->setUpdateAllowList(array_map( - static fn (ResolvedPackageRequest $resolvedPackageRequest) => $resolvedPackageRequest->requestedPackageAndVersion->package, - $resolvedPackagesToRemove, - )); + $composerInstaller->setUpdateAllowList(ResolvedPackageRequest::requestedPackageNames($resolvedPackagesToRemove)); } $resultCode = $composerInstaller->run(); diff --git a/src/DependencyResolver/ResolvedPackageRequest.php b/src/DependencyResolver/ResolvedPackageRequest.php index 41107ecf..af5ae722 100644 --- a/src/DependencyResolver/ResolvedPackageRequest.php +++ b/src/DependencyResolver/ResolvedPackageRequest.php @@ -4,6 +4,10 @@ namespace Php\Pie\DependencyResolver; +use Php\Pie\ExtensionName; + +use function array_map; + /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class ResolvedPackageRequest { @@ -12,4 +16,43 @@ public function __construct( public readonly RequestedPackageAndVersion $requestedPackageAndVersion, ) { } + + /** + * @param list $resolvedPackageRequests + * + * @return list + */ + public static function extensionNames(array $resolvedPackageRequests): array + { + return array_map( + static fn (self $resolvedPackageRequest): ExtensionName => $resolvedPackageRequest->piePackage->extensionName(), + $resolvedPackageRequests, + ); + } + + /** + * @param list $resolvedPackageRequests + * + * @return list + */ + public static function requestedPackageNames(array $resolvedPackageRequests): array + { + return array_map( + static fn (self $resolvedPackageRequest): string => $resolvedPackageRequest->requestedPackageAndVersion->package, + $resolvedPackageRequests, + ); + } + + /** + * @param non-empty-list $resolvedPackageRequests + * + * @return non-empty-list + */ + public static function piePackages(array $resolvedPackageRequests): array + { + return array_map( + static fn (self $resolvedPackageRequest): Package => $resolvedPackageRequest->piePackage, + $resolvedPackageRequests, + ); + } } diff --git a/test/unit/DependencyResolver/ResolvedPackageRequestTest.php b/test/unit/DependencyResolver/ResolvedPackageRequestTest.php new file mode 100644 index 00000000..8b7494d7 --- /dev/null +++ b/test/unit/DependencyResolver/ResolvedPackageRequestTest.php @@ -0,0 +1,70 @@ +method('getPrettyName')->willReturn($prettyName); + $composerPackage->method('getPrettyVersion')->willReturn($prettyVersion); + $composerPackage->method('getType')->willReturn('php-ext'); + + return Package::fromComposerCompletePackage($composerPackage); + } + + public function testExtensionNames(): void + { + $resolvedPackageRequests = [ + new ResolvedPackageRequest(self::packageNamed('foo/bar', '1.0.0'), new RequestedPackageAndVersion('foo/bar', null)), + new ResolvedPackageRequest(self::packageNamed('baz/qux', '2.0.0'), new RequestedPackageAndVersion('baz/qux', '^2.0')), + ]; + + self::assertSame( + ['bar', 'qux'], + array_map(static fn ($extensionName) => $extensionName->name(), ResolvedPackageRequest::extensionNames($resolvedPackageRequests)), + ); + } + + public function testRequestedPackageNames(): void + { + $resolvedPackageRequests = [ + new ResolvedPackageRequest(self::packageNamed('foo/bar', '1.0.0'), new RequestedPackageAndVersion('foo/bar', null)), + new ResolvedPackageRequest(self::packageNamed('baz/qux', '2.0.0'), new RequestedPackageAndVersion('baz/qux', '^2.0')), + ]; + + self::assertSame( + ['foo/bar', 'baz/qux'], + ResolvedPackageRequest::requestedPackageNames($resolvedPackageRequests), + ); + } + + public function testPiePackages(): void + { + $packageA = self::packageNamed('foo/bar', '1.0.0'); + $packageB = self::packageNamed('baz/qux', '2.0.0'); + + $resolvedPackageRequests = [ + new ResolvedPackageRequest($packageA, new RequestedPackageAndVersion('foo/bar', null)), + new ResolvedPackageRequest($packageB, new RequestedPackageAndVersion('baz/qux', '^2.0')), + ]; + + self::assertSame( + [$packageA, $packageB], + ResolvedPackageRequest::piePackages($resolvedPackageRequests), + ); + } +}