diff --git a/.noai b/.noai new file mode 100644 index 00000000..e69de29b diff --git a/README.md b/README.md index 64dfe8b8..bc232dc8 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ ## What is PIE? -PIE is a new installer for PHP extensions, intended to eventually replace PECL. -It is distributed as a [PHAR](https://www.php.net/manual/en/intro.phar.php), -just like Composer, and works in a similar way to Composer, but it installs PHP -extensions (PHP Modules or Zend Extensions) to your PHP installation, rather -than pulling PHP packages into your project or library. +PIE is the official installer for PHP extensions, which replaces +[PECL](https://pecl.php.net/) (which is now deprecated). PIE is distributed as a +[PHAR](https://www.php.net/manual/en/intro.phar.php), just like Composer, and +works in a similar way to Composer, but it installs PHP extensions (PHP Modules +or Zend Extensions) to your PHP installation, rather than pulling PHP packages +into your project or library. # Using PIE - what do I need to get started? @@ -15,12 +16,8 @@ than pulling PHP packages into your project or library. You will need PHP 8.1 or newer to run PIE, but PIE can install an extension to any other installed PHP version. -On Linux, you will need a build toolchain installed. On Debian/Ubuntu type -systems, you could run something like: - -```shell -sudo apt install gcc make autoconf libtool bison re2c pkg-config php-dev -``` +On Linux/OSX, if any build tools needed are missing, PIE will ask if you would +like to automatically install them first (this is a new feature in 1.4.0). On Windows, you do not need any build toolchain installed, since PHP extensions for Windows are distributed as pre-compiled packages containing the extension @@ -38,7 +35,9 @@ Further installation details can be found in the [usage](./docs/usage.md) docs. This documentation assumes you have moved `pie.phar` into your `$PATH`, e.g. `/usr/local/bin/pie` on non-Windows systems or created an alias in your shell RC file. -## Installing a single extension using PIE +## Using PIE + +### Installing a single extension using PIE You can install an extension using the `install` command. For example, to install the `example_pie_extension` extension, you would run: @@ -57,7 +56,7 @@ You must now add "extension=example_pie_extension" to your php.ini $ ``` -## Installing all extensions for a PHP project +### Installing all extensions for a PHP project When in your PHP project, you can install any missing top-level extensions: @@ -87,6 +86,12 @@ The following packages may be suitable, which would you like to install: Finished checking extensions. ``` +> [!TIP] +> If you are running PIE in a non-interactive shell (for example, CI, a +> container), pass the `--allow-non-interactive-project-install` flag to run +> this command. It may still fail if more than one PIE package provides a +> particular extension. + ## Extensions that support PIE A list of extensions that support PIE can be found on @@ -105,6 +110,6 @@ A list of extensions that support PIE can be found on If you are an extension maintainer wanting to add PIE support to your extension, please read [extension-maintainers](./docs/extension-maintainers.md). -## More documentation... +# More documentation... The full documentation for PIE can be found in [usage](./docs/usage.md) docs. diff --git a/composer.json b/composer.json index 9bb4f3d6..2f09d921 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "bnf/phpstan-psr-container": "^1.1", "doctrine/coding-standard": "^14.0.0", "phpstan/phpstan": "^2.1.38", + "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-webmozart-assert": "^2.0", "phpunit/phpunit": "^10.5.63" }, diff --git a/composer.lock b/composer.lock index 654bc2d6..06d3fa8a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6392877b0b53e6d18d3d156173dcbba3", + "content-hash": "1138a5a4004fa55c3068062f3a2adc43", "packages": [ { "name": "composer/ca-bundle", @@ -3362,6 +3362,62 @@ ], "time": "2026-01-30T17:12:46+00:00" }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.0.16", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.32" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" + }, + "time": "2026-02-14T09:05:21+00:00" + }, { "name": "phpstan/phpstan-webmozart-assert", "version": "2.0.0", diff --git a/docs/extension-maintainers.md b/docs/extension-maintainers.md index 0507754e..40e8b4c3 100644 --- a/docs/extension-maintainers.md +++ b/docs/extension-maintainers.md @@ -345,11 +345,16 @@ The list of accepted OS families: "windows", "bsd", "darwin", "solaris", "linux" #### Extension dependencies -Extension authors may define some dependencies in `require`, but practically, +Extension authors may define some dependencies in `require`, but typically, most extensions would not need to define dependencies, except for the PHP -versions supported by the extension. Dependencies on other extensions may be -defined, for example `ext-json`. However, dependencies on a regular PHP package -(such as `monolog/monolog`) SHOULD NOT be specified in your `require` section. +versions supported by the extension, and system libraries. + +Dependencies on a regular PHP package (such as `monolog/monolog`) SHOULD NOT be +specified in your extension's `require` section. + +##### Dependencies on other extensions + +Dependencies on other extensions may be defined, for example `ext-json`. It is worth noting that if your extension does define a dependency on another dependency, and this is not available, someone installing your extension would @@ -360,6 +365,47 @@ Cannot use myvendor/myextension's latest version 1.2.3 as it requires ext-something * which is missing from your platform. ``` +##### System Library Dependencies + +In PIE 1.4.0, the ability for extension authors to define system library +dependencies was added, and in some cases, automatically install them. + +The following libraries are supported at the moment. **If you would like to add +a library, please [open a discussion](https://github.com/php/pie/discussions) +in the first instance.** Don't just open a PR without discussing first please! + +We are adding libraries and improving this feature over time. If the automatic +install of a system dependency that is supported below in your package manager +is NOT working, then please [report a bug](https://github.com/php/pie/issues). + +| Library | Checked by PIE | Auto-installs in | +|---------------|----------------|--------------------| +| lib-curl | ✅ | apt, apk, dnf, yum | +| lib-enchant | ✅ | ❌ | +| lib-enchant-2 | ✅ | ❌ | +| lib-sodium | ✅ | apt, apk, dnf, yum | +| lib-ffi | ✅ | apt, apk, dnf, yum | +| lib-xslt | ✅ | apt, apk, dnf, yum | +| lib-zip | ✅ | apt, apk, dnf, yum | +| lib-png | ✅ | ❌ | +| lib-avif | ✅ | ❌ | +| lib-webp | ✅ | ❌ | +| lib-jpeg | ✅ | apt, apk, dnf, yum | +| lib-xpm | ✅ | ❌ | +| lib-freetype2 | ✅ | ❌ | +| lib-gdlib | ✅ | ❌ | +| lib-gmp | ✅ | ❌ | +| lib-sasl | ✅ | ❌ | +| lib-onig | ✅ | ❌ | +| lib-odbc | ✅ | ❌ | +| lib-capstone | ✅ | ❌ | +| lib-pcre | ✅ | ❌ | +| lib-edit | ✅ | ❌ | +| lib-snmp | ✅ | ❌ | +| lib-argon2 | ✅ | ❌ | +| lib-uriparser | ✅ | ❌ | +| lib-exslt | ✅ | ❌ | + #### Checking the extension will work First up, you can use `composer validate` to check your `composer.json` is diff --git a/docs/usage.md b/docs/usage.md index 174c2ff0..8f797cdd 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -281,6 +281,31 @@ pie install example/some-extension --with-some-library-name=/path/to/the/lib pie install example/some-extension --with-some-library-name=/path/to/the/lib --enable-some-functionality ``` +### Build tools check + +PIE will attempt to check the presence of build tools (such as gcc, make, etc.) +before running. If any are missing, an interactive prompt will ask if you would +like to install the missing tools. If you are running in non-interactive mode +(for example, in a CI pipeline, container build, etc), PIE will **not** +install these tools automatically. If you would like to install the build tools +in a non-interactive terminal, pass the `--auto-install-build-tools` and the +prompt will be skipped. + +To skip the build tools check entirely, pass the `--no-build-tools-check` flag. + +### System library dependencies check + +PIE will attempt to check the presence of system library dependencies before +installing an extension. If any are missing, an interactive prompt will ask if +you would like to install the missing tools. If you are running in +non-interactive mode (for example, in a CI pipeline, container build, etc), PIE +will **not** install these dependencies automatically. If you would like to +install the system dependencies in a non-interactive terminal, pass the +`--auto-install-system-dependencies` and the prompt will be skipped. + +To skip the dependencies check entirely, pass the +`--no-system-dependencies-check` flag. + ### Configuring the INI file PIE will automatically try to enable the extension by adding `extension=...` or diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 785a6d83..484f95bd 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -288,6 +288,12 @@ parameters: count: 1 path: src/Platform.php + - + message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(Composer\\Package\\BasePackage\)\: mixed\)\|null, Closure\(Composer\\Package\\CompletePackageInterface\)\: Php\\Pie\\DependencyResolver\\Package given\.$#' + identifier: argument.type + count: 1 + path: src/Platform/InstalledPiePackages.php + - message: '#^Call to function array_key_exists\(\) with 1 and array\{non\-falsy\-string, non\-empty\-string, non\-empty\-string\} will always evaluate to true\.$#' identifier: function.alreadyNarrowedType @@ -354,18 +360,6 @@ parameters: count: 1 path: test/integration/Command/InstallCommandTest.php - - - message: '#^Parameter \#1 \$originalClassName of method PHPUnit\\Framework\\TestCase\:\:createMock\(\) expects class\-string\, string given\.$#' - identifier: argument.type - count: 1 - path: test/integration/Command/InstallExtensionsForProjectCommandTest.php - - - - message: '#^Unable to resolve the template type RealInstanceType in call to method PHPUnit\\Framework\\TestCase\:\:createMock\(\)$#' - identifier: argument.templateType - count: 1 - path: test/integration/Command/InstallExtensionsForProjectCommandTest.php - - message: '#^Call to function assert\(\) with true will always evaluate to true\.$#' identifier: function.alreadyNarrowedType diff --git a/phpstan.neon b/phpstan.neon index 9d3ff539..80f87e1d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,7 @@ includes: - phpstan-baseline.neon - vendor/bnf/phpstan-psr-container/extension.neon - vendor/phpstan/phpstan-webmozart-assert/extension.neon + - vendor/phpstan/phpstan-phpunit/extension.neon parameters: level: 10 diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index f37aa0b0..062eac76 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -11,17 +11,19 @@ use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; +use Php\Pie\DependencyResolver\DependencyInstaller\PrescanSystemDependencies; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\UnableToResolveRequirement; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; +use Php\Pie\Platform\PackageManager; use Php\Pie\SelfManage\BuildTools\CheckAllBuildTools; -use Php\Pie\SelfManage\BuildTools\PackageManager; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; use function sprintf; @@ -34,6 +36,7 @@ final class BuildCommand extends Command public function __construct( private readonly ContainerInterface $container, private readonly DependencyResolver $dependencyResolver, + private readonly PrescanSystemDependencies $prescanSystemDependencies, private readonly ComposerIntegrationHandler $composerIntegrationHandler, private readonly FindMatchingPackages $findMatchingPackages, private readonly IOInterface $io, @@ -88,6 +91,22 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); + if (CommandHelper::shouldCheckSystemDependencies($input)) { + 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 { $package = ($this->dependencyResolver)( $composer, diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 62f08e7c..5fb3757f 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -63,6 +63,8 @@ final class CommandHelper private const OPTION_NO_CACHE = 'no-cache'; private const OPTION_AUTO_INSTALL_BUILD_TOOLS = 'auto-install-build-tools'; private const OPTION_SUPPRESS_BUILD_TOOLS_CHECK = 'no-build-tools-check'; + private const OPTION_AUTO_INSTALL_SYSTEM_DEPENDENCIES = 'auto-install-system-dependencies'; + private const OPTION_SUPPRESS_SYSTEM_DEPENDENCIES_CHECK = 'no-system-dependencies-check'; private function __construct() { @@ -154,6 +156,19 @@ public static function configureDownloadBuildInstallOptions(Command $command, bo 'Do not perform the check to see if build tools are present on the system.', ); + $command->addOption( + self::OPTION_AUTO_INSTALL_SYSTEM_DEPENDENCIES, + null, + InputOption::VALUE_NONE, + 'If system dependencies missing, automatically install them, instead of prompting.', + ); + $command->addOption( + self::OPTION_SUPPRESS_SYSTEM_DEPENDENCIES_CHECK, + null, + InputOption::VALUE_NONE, + 'Do not perform the check to see if system dependencies are present on the system.', + ); + /** * Allows additional options for the `./configure` command to be passed here. * Note, this means you probably need to call {@see self::validateInput()} to validate the input manually... @@ -267,6 +282,22 @@ public static function shouldCheckForBuildTools(InputInterface $input): bool || ! $input->getOption(self::OPTION_SUPPRESS_BUILD_TOOLS_CHECK); } + public static function autoInstallSystemDependencies(InputInterface $input): bool + { + return $input->hasOption(self::OPTION_AUTO_INSTALL_SYSTEM_DEPENDENCIES) + && $input->getOption(self::OPTION_AUTO_INSTALL_SYSTEM_DEPENDENCIES); + } + + public static function shouldCheckSystemDependencies(InputInterface $input): bool + { + if (Platform::isWindows()) { + return false; + } + + return ! $input->hasOption(self::OPTION_SUPPRESS_SYSTEM_DEPENDENCIES_CHECK) + || ! $input->getOption(self::OPTION_SUPPRESS_SYSTEM_DEPENDENCIES_CHECK); + } + public static function requestedNameAndVersionPair(InputInterface $input): RequestedPackageAndVersion { $requestedPackageString = $input->getArgument(self::ARG_REQUESTED_PACKAGE_AND_VERSION); diff --git a/src/Command/InfoCommand.php b/src/Command/InfoCommand.php index d096b49c..21112a61 100644 --- a/src/Command/InfoCommand.php +++ b/src/Command/InfoCommand.php @@ -5,17 +5,15 @@ namespace Php\Pie\Command; use Composer\IO\IOInterface; -use Composer\Semver\Constraint\Constraint; -use Php\Pie\ComposerIntegration\PhpBinaryPathBasedPlatformRepository; use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; use Php\Pie\DependencyResolver\DependencyResolver; +use Php\Pie\DependencyResolver\FetchDependencyStatuses; use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\UnableToResolveRequirement; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; -use Php\Pie\Platform\InstalledPiePackages; use Php\Pie\Platform\ThreadSafetyMode; use Php\Pie\Util\Emoji; use Psr\Container\ContainerInterface; @@ -24,7 +22,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use function array_key_exists; use function count; use function in_array; use function sprintf; @@ -38,6 +35,7 @@ final class InfoCommand extends Command public function __construct( private readonly ContainerInterface $container, private readonly DependencyResolver $dependencyResolver, + private readonly FetchDependencyStatuses $fetchDependencyStatuses, private readonly FindMatchingPackages $findMatchingPackages, private readonly IOInterface $io, ) { @@ -127,30 +125,11 @@ public function execute(InputInterface $input, OutputInterface $output): int )); $this->io->write("\nDependencies:"); - $requires = $package->composerPackage()->getRequires(); - - if (count($requires) > 0) { - /** @var array> $platformConstraints */ - $platformConstraints = []; - $composerPlatform = new PhpBinaryPathBasedPlatformRepository($targetPlatform->phpBinaryPath, $composer, new InstalledPiePackages(), null); - foreach ($composerPlatform->getPackages() as $platformPackage) { - $platformConstraints[$platformPackage->getName()][] = new Constraint('==', $platformPackage->getVersion()); - } - foreach ($requires as $requireName => $requireLink) { - $packageStatus = sprintf(' %s: %s %%s', $requireName, $requireLink->getConstraint()->getPrettyString()); - if (! array_key_exists($requireName, $platformConstraints)) { - $this->io->write(sprintf($packageStatus, Emoji::PROHIBITED . ' (not installed)')); - continue; - } - - foreach ($platformConstraints[$requireName] as $constraint) { - if ($requireLink->getConstraint()->matches($constraint)) { - $this->io->write(sprintf($packageStatus, Emoji::GREEN_CHECKMARK)); - } else { - $this->io->write(sprintf($packageStatus, Emoji::PROHIBITED . ' (your version is ' . $constraint->getVersion() . ')')); - } - } + $dependencyStatuses = ($this->fetchDependencyStatuses)($targetPlatform, $composer, $package->composerPackage()); + if (count($dependencyStatuses) > 0) { + foreach ($dependencyStatuses as $dependencyStatus) { + $this->io->write(' ' . $dependencyStatus->asPrettyString()); } } else { $this->io->write(' No dependencies.'); diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index d7dbd16e..c74c8f7d 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -11,18 +11,20 @@ use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; +use Php\Pie\DependencyResolver\DependencyInstaller\PrescanSystemDependencies; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\UnableToResolveRequirement; use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages; +use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPlatform; use Php\Pie\SelfManage\BuildTools\CheckAllBuildTools; -use Php\Pie\SelfManage\BuildTools\PackageManager; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; use function sprintf; @@ -35,6 +37,7 @@ final class InstallCommand extends Command public function __construct( private readonly ContainerInterface $container, private readonly DependencyResolver $dependencyResolver, + private readonly PrescanSystemDependencies $prescanSystemDependencies, private readonly ComposerIntegrationHandler $composerIntegrationHandler, private readonly InvokeSubCommand $invokeSubCommand, private readonly FindMatchingPackages $findMatchingPackages, @@ -102,6 +105,22 @@ public function execute(InputInterface $input, OutputInterface $output): int ), ); + if (CommandHelper::shouldCheckSystemDependencies($input)) { + 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 { $package = ($this->dependencyResolver)( $composer, diff --git a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php index e29f941b..2e60dd95 100644 --- a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php +++ b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php @@ -146,6 +146,10 @@ private function detectLibraryWithPkgConfig(string $alias, string $library): voi $this->addPackage($lib); } + /** + * Instructions for PIE to install these libraries, if they are missing, should be added + * into {@see \Php\Pie\DependencyResolver\DependencyInstaller\SystemDependenciesDefinition::default()} + */ private function addLibrariesUsingPkgConfig(): void { $this->detectLibraryWithPkgConfig('curl', 'libcurl'); diff --git a/src/Container.php b/src/Container.php index a262c6cc..076e64f8 100644 --- a/src/Container.php +++ b/src/Container.php @@ -28,6 +28,7 @@ use Php\Pie\Command\UninstallCommand; use Php\Pie\ComposerIntegration\MinimalHelperSet; use Php\Pie\ComposerIntegration\QuieterConsoleIO; +use Php\Pie\DependencyResolver\DependencyInstaller\SystemDependenciesDefinition; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\ResolveDependencyWithComposer; use Php\Pie\Downloading\GithubPackageReleaseAssets; @@ -39,6 +40,7 @@ use Php\Pie\Installing\UninstallUsingUnlink; use Php\Pie\Installing\UnixInstall; use Php\Pie\Installing\WindowsInstall; +use Php\Pie\Platform\PackageManager; use Php\Pie\SelfManage\BuildTools\CheckAllBuildTools; use Psr\Container\ContainerInterface; use Symfony\Component\Console\ConsoleEvents; @@ -209,6 +211,20 @@ static function (): CheckAllBuildTools { $container->alias(Ini\RemoveIniEntryWithFileGetContents::class, Ini\RemoveIniEntry::class); + $container->singleton( + PackageManager::class, + static function (): PackageManager|null { + return PackageManager::detect(); + }, + ); + + $container->singleton( + SystemDependenciesDefinition::class, + static function (): SystemDependenciesDefinition { + return SystemDependenciesDefinition::default(); + }, + ); + return $container; } diff --git a/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php b/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php new file mode 100644 index 00000000..4b3a303e --- /dev/null +++ b/src/DependencyResolver/DependencyInstaller/PrescanSystemDependencies.php @@ -0,0 +1,150 @@ +packageManager === null) { + $this->io->writeError('Skipping pre-scan of system dependencies, as a supported package manager could not be detected.', verbosity: IOInterface::VERBOSE); + + return; + } + + $this->io->write(sprintf('Checking system dependencies are present for extension %s', $requestedNameAndVersion->prettyNameAndVersion()), verbosity: IOInterface::VERBOSE); + + $package = ($this->dependencyResolver)( + $composer, + $targetPlatform, + $requestedNameAndVersion, + true, + ); + + $unmetDependencies = array_filter( + ($this->fetchDependencyStatuses)($targetPlatform, $composer, $package->composerPackage()), + static function (DependencyStatus $dependencyStatus): bool { + return ! $dependencyStatus->satisfied(); + }, + ); + + if (! count($unmetDependencies)) { + $this->io->write('All system dependencies are already installed.', verbosity: IOInterface::VERBOSE); + + return; + } + + $this->io->write( + sprintf('Extension %s has unmet dependencies: %s', $requestedNameAndVersion->prettyNameAndVersion(), implode(', ', array_map(static fn (DependencyStatus $status): string => $status->name, $unmetDependencies))), + verbosity: IOInterface::VERBOSE, + ); + + $packageManagerPackages = array_values(array_unique(array_filter(array_map( + fn (DependencyStatus $unmetDependency): string|null => $this->packageManagerPackageForDependency($unmetDependency, $this->packageManager), + $unmetDependencies, + )))); + + if (! count($packageManagerPackages)) { + $this->io->writeError('No system dependencies could be installed automatically by PIE.', verbosity: IOInterface::VERBOSE); + + return; + } + + $proposedInstallCommand = implode(' ', $this->packageManager->installCommand($packageManagerPackages)); + + if (! $this->io->isInteractive() && ! $autoInstallIfMissing) { + $this->io->writeError('You are not running in interactive mode, and you did not provide the --auto-install-system-dependencies flag.'); + $this->io->writeError('You may need to run: ' . $proposedInstallCommand . ''); + $this->io->writeError(''); + + return; + } + + $this->io->write(sprintf('Need to install missing system dependencies: %s', $proposedInstallCommand)); + + if ($this->io->isInteractive() && ! $autoInstallIfMissing) { + if (! $this->io->askConfirmation('Would you like to install them now?', false)) { + $this->io->write('Ok, but things might not work. Just so you know.'); + + return; + } + } + + try { + $this->packageManager->install($packageManagerPackages); + + $this->io->write('Missing system dependencies have been installed.'); + } catch (Throwable $anything) { + $this->io->writeError(sprintf('Failed to install missing system dependencies: %s', $anything->getMessage())); + } + } + + private function packageManagerPackageForDependency(DependencyStatus $unmetDependency, PackageManager $packageManager): string|null + { + $depName = str_replace('lib-', '', $unmetDependency->name); + + if (! array_key_exists($depName, $this->systemDependenciesDefinition->definition)) { + $this->io->writeError( + sprintf('Could not automatically install "%s", as PIE does not have the package manager definition.', $unmetDependency->name), + verbosity: IOInterface::VERBOSE, + ); + + return null; + } + + if (! array_key_exists($packageManager->value, $this->systemDependenciesDefinition->definition[$depName])) { + $this->io->writeError( + sprintf('Could not automatically install "%s", as PIE does not have a definition for "%s"', $unmetDependency->name, $packageManager->value), + verbosity: IOInterface::VERBOSE, + ); + + return null; + } + + $packageManagerPackage = $this->systemDependenciesDefinition->definition[$depName][$packageManager->value]; + + // Note: ideally, we should also parse the version constraint. This initial iteration will ignore that, to be improved later. + $this->io->write( + sprintf('Adding %s package %s to be installed for %s', $packageManager->value, $packageManagerPackage, $unmetDependency->name), + verbosity: IOInterface::VERBOSE, + ); + + return $packageManagerPackage; + } +} diff --git a/src/DependencyResolver/DependencyInstaller/SystemDependenciesDefinition.php b/src/DependencyResolver/DependencyInstaller/SystemDependenciesDefinition.php new file mode 100644 index 00000000..d29adcef --- /dev/null +++ b/src/DependencyResolver/DependencyInstaller/SystemDependenciesDefinition.php @@ -0,0 +1,61 @@ +> $definition */ + public function __construct(public readonly array $definition) + { + } + + /** + * Checks for the existence of these libraries should be added into + * {@see \Php\Pie\ComposerIntegration\PhpBinaryPathBasedPlatformRepository::addLibrariesUsingPkgConfig()} + */ + public static function default(): self + { + return new self([ + 'sodium' => [ + PackageManager::Apt->value => 'libsodium-dev', + PackageManager::Apk->value => 'libsodium-dev', + PackageManager::Dnf->value => 'pkgconfig(libsodium)', + PackageManager::Yum->value => 'pkgconfig(libsodium)', + ], + 'jpeg' => [ + PackageManager::Apt->value => 'libjpeg-dev', + PackageManager::Apk->value => 'libjpeg-turbo-dev', + PackageManager::Dnf->value => 'pkgconfig(libjpeg)', + PackageManager::Yum->value => 'pkgconfig(libjpeg)', + ], + 'zip' => [ + PackageManager::Apt->value => 'libzip-dev', + PackageManager::Apk->value => 'libzip-dev', + PackageManager::Dnf->value => 'pkgconfig(libzip)', + PackageManager::Yum->value => 'pkgconfig(libzip)', + ], + 'xslt' => [ + PackageManager::Apt->value => 'libxslt1-dev', + PackageManager::Apk->value => 'libxslt-dev', + PackageManager::Dnf->value => 'pkgconfig(libxslt)', + PackageManager::Yum->value => 'pkgconfig(libxslt)', + ], + 'ffi' => [ + PackageManager::Apt->value => 'libffi-dev', + PackageManager::Apk->value => 'libffi-dev', + PackageManager::Dnf->value => 'pkgconfig(libffi)', + PackageManager::Yum->value => 'pkgconfig(libffi)', + ], + 'curl' => [ + PackageManager::Apt->value => 'libcurl4-openssl-dev', + PackageManager::Apk->value => 'curl-dev', + PackageManager::Dnf->value => 'pkgconfig(libcurl)', + PackageManager::Yum->value => 'pkgconfig(libcurl)', + ], + ]); + } +} diff --git a/src/DependencyResolver/DependencyStatus.php b/src/DependencyResolver/DependencyStatus.php new file mode 100644 index 00000000..7df5c063 --- /dev/null +++ b/src/DependencyResolver/DependencyStatus.php @@ -0,0 +1,41 @@ +name, $this->requireConstraint->getPrettyString()); + if ($this->installedVersion === null) { + return sprintf($statusTemplate, Emoji::PROHIBITED . ' (not installed)'); + } + + if (! $this->requireConstraint->matches($this->installedVersion)) { + return sprintf($statusTemplate, Emoji::PROHIBITED . ' (your version is ' . $this->installedVersion->getVersion() . ')'); + } + + return sprintf($statusTemplate, Emoji::GREEN_CHECKMARK); + } + + public function satisfied(): bool + { + return $this->installedVersion !== null && $this->requireConstraint->matches($this->installedVersion); + } +} diff --git a/src/DependencyResolver/FetchDependencyStatuses.php b/src/DependencyResolver/FetchDependencyStatuses.php new file mode 100644 index 00000000..4269173f --- /dev/null +++ b/src/DependencyResolver/FetchDependencyStatuses.php @@ -0,0 +1,48 @@ + */ + public function __invoke(TargetPlatform $targetPlatform, Composer $composer, CompletePackageInterface $package): array + { + $requires = $package->getRequires(); + + if (count($requires) <= 0) { + return []; + } + + /** @var array $platformConstraints */ + $platformConstraints = []; + $composerPlatform = new PhpBinaryPathBasedPlatformRepository($targetPlatform->phpBinaryPath, $composer, new InstalledPiePackages(), null); + foreach ($composerPlatform->getPackages() as $platformPackage) { + $platformConstraints[$platformPackage->getName()] = new Constraint('==', $platformPackage->getVersion()); + } + + $checkedPackages = []; + + foreach ($requires as $requireName => $requireLink) { + $checkedPackages[] = new DependencyStatus( + $requireName, + $requireLink->getConstraint(), + array_key_exists($requireName, $platformConstraints) ? $platformConstraints[$requireName] : null, + ); + } + + return $checkedPackages; + } +} diff --git a/src/Platform/InstalledPiePackages.php b/src/Platform/InstalledPiePackages.php index efc2353d..4eb5346f 100644 --- a/src/Platform/InstalledPiePackages.php +++ b/src/Platform/InstalledPiePackages.php @@ -7,7 +7,9 @@ use Composer\Composer; use Composer\Package\BasePackage; use Composer\Package\CompletePackageInterface; +use InvalidArgumentException; use Php\Pie\DependencyResolver\Package; +use Php\Pie\ExtensionName; use function array_combine; use function array_filter; @@ -38,6 +40,12 @@ static function (CompletePackageInterface $package): Package { ->getLocalRepository() ->getPackages(), static function (BasePackage $basePackage): bool { + try { + ExtensionName::determineFromComposerPackage($basePackage); + } catch (InvalidArgumentException) { + return false; + } + return $basePackage instanceof CompletePackageInterface; }, ), diff --git a/src/SelfManage/BuildTools/PackageManager.php b/src/Platform/PackageManager.php similarity index 98% rename from src/SelfManage/BuildTools/PackageManager.php rename to src/Platform/PackageManager.php index da9cac15..561a23a4 100644 --- a/src/SelfManage/BuildTools/PackageManager.php +++ b/src/Platform/PackageManager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Php\Pie\SelfManage\BuildTools; +namespace Php\Pie\Platform; use Php\Pie\File\Sudo; use Php\Pie\Platform; diff --git a/src/SelfManage/BuildTools/BinaryBuildToolFinder.php b/src/SelfManage/BuildTools/BinaryBuildToolFinder.php index 786af850..d5a0f9aa 100644 --- a/src/SelfManage/BuildTools/BinaryBuildToolFinder.php +++ b/src/SelfManage/BuildTools/BinaryBuildToolFinder.php @@ -4,6 +4,7 @@ namespace Php\Pie\SelfManage\BuildTools; +use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPlatform; use Symfony\Component\Process\ExecutableFinder; diff --git a/src/SelfManage/BuildTools/CheckAllBuildTools.php b/src/SelfManage/BuildTools/CheckAllBuildTools.php index c8bd73bd..715a25f4 100644 --- a/src/SelfManage/BuildTools/CheckAllBuildTools.php +++ b/src/SelfManage/BuildTools/CheckAllBuildTools.php @@ -5,6 +5,7 @@ namespace Php\Pie\SelfManage\BuildTools; use Composer\IO\IOInterface; +use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPlatform; use Throwable; diff --git a/src/SelfManage/BuildTools/PhpizeBuildToolFinder.php b/src/SelfManage/BuildTools/PhpizeBuildToolFinder.php index aae61d68..ec10cf3a 100644 --- a/src/SelfManage/BuildTools/PhpizeBuildToolFinder.php +++ b/src/SelfManage/BuildTools/PhpizeBuildToolFinder.php @@ -4,6 +4,7 @@ namespace Php\Pie\SelfManage\BuildTools; +use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPhp\PhpizePath; use Php\Pie\Platform\TargetPlatform; use RuntimeException; diff --git a/test/end-to-end/Dockerfile b/test/end-to-end/Dockerfile index 50b3a374..09e941ee 100644 --- a/test/end-to-end/Dockerfile +++ b/test/end-to-end/Dockerfile @@ -31,3 +31,30 @@ RUN apt-get remove --allow-remove-essential -y apt USER linuxbrew RUN pie install --auto-install-build-tools -v asgrim/example-pie-extension RUN pie show + +FROM ubuntu AS test_pie_installs_system_deps_on_ubuntu +RUN apt-get update && apt install -y unzip curl wget gcc make autoconf libtool bison re2c pkg-config libzip-dev libssl-dev libonig-dev +RUN mkdir -p /opt/php \ + && mkdir -p /tmp/php \ + && cd /tmp/php \ + && wget -O php.tgz https://www.php.net/distributions/php-8.4.17.tar.gz \ + && tar zxf php.tgz \ + && rm php.tgz \ + && cd * \ + && ./buildconf --force \ + && ./configure --prefix=/opt/php --disable-all --enable-phar --enable-filter --enable-mbstring --with-openssl --with-iconv --with-zip \ + && make -j$(nproc) \ + && make install +ENV PATH="$PATH:/opt/php/bin" +COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie +RUN pie install -v --auto-install-system-dependencies php/sodium + +FROM alpine AS test_pie_installs_system_deps_on_alpine +RUN apk add php php-phar php-mbstring php-iconv php-openssl bzip2-dev libbz2 build-base autoconf bison re2c libtool php84-dev +COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie +RUN pie install -v --auto-install-system-dependencies php/sodium + +FROM fedora AS test_pie_installs_system_deps_on_fedora +RUN dnf install -y php php-pecl-zip unzip gcc make autoconf bison re2c libtool php-devel +COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie +RUN pie install -v --auto-install-system-dependencies php/sodium diff --git a/test/integration/Command/InstallExtensionsForProjectCommandTest.php b/test/integration/Command/InstallExtensionsForProjectCommandTest.php index cad216ca..ad9f6cfa 100644 --- a/test/integration/Command/InstallExtensionsForProjectCommandTest.php +++ b/test/integration/Command/InstallExtensionsForProjectCommandTest.php @@ -58,17 +58,15 @@ public function setUp(): void $container->method('get')->willReturnCallback( /** @param class-string $service */ function (string $service): mixed { - switch ($service) { - case QuieterConsoleIO::class: - return new QuieterConsoleIO( - new ArrayInput([]), - new BufferedOutput(), - new MinimalHelperSet(['question' => new QuestionHelper()]), - ); - - default: - return $this->createMock($service); - } + /** @var class-string $service */ + return match ($service) { + QuieterConsoleIO::class => new QuieterConsoleIO( + new ArrayInput([]), + new BufferedOutput(), + new MinimalHelperSet(['question' => new QuestionHelper()]), + ), + default => $this->createMock($service), + }; }, ); diff --git a/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php b/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php new file mode 100644 index 00000000..8f292c0e --- /dev/null +++ b/test/unit/DependencyResolver/DependencyInstaller/PrescanSystemDependenciesTest.php @@ -0,0 +1,297 @@ +dependencyResolver = $this->createMock(DependencyResolver::class); + $this->fetchDependencyStatuses = $this->createMock(FetchDependencyStatuses::class); + $this->io = new BufferIO(verbosity: StreamOutput::VERBOSITY_VERBOSE); + $this->composer = $this->createMock(Composer::class); + $this->targetPlatform = $this->createMock(TargetPlatform::class); + } + + public function testNoPackageManager(): void + { + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([]), + null, + $this->io, + ); + + ($scanner)($this->composer, $this->targetPlatform, new RequestedPackageAndVersion('foo/foo', null), true); + + self::assertStringContainsString( + 'Skipping pre-scan of system dependencies, as a supported package manager could not be detected.', + $this->io->getOutput(), + ); + } + + public function testAllDependenciesSatisfied(): void + { + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([]), + PackageManager::Test, + $this->io, + ); + + $request = new RequestedPackageAndVersion('foo/foo', null); + $composerPackage = new CompletePackage('foo/foo', '1.0.0.0', '1.0.0'); + $piePackage = Package::fromComposerCompletePackage($composerPackage); + $this->dependencyResolver->expects(self::once()) + ->method('__invoke') + ->with($this->composer, $this->targetPlatform, $request, true) + ->willReturn($piePackage); + + $versionParser = new VersionParser(); + + $this->fetchDependencyStatuses->expects(self::once()) + ->method('__invoke') + ->with($this->targetPlatform, $this->composer, $composerPackage) + ->willReturn([ + new DependencyStatus('lib-foo', $versionParser->parseConstraints('^1.0'), new Constraint('=', '1.0.0.0')), + new DependencyStatus('lib-bar', $versionParser->parseConstraints('^2.0'), new Constraint('=', '2.5.1.0')), + ]); + + ($scanner)($this->composer, $this->targetPlatform, $request, true); + + self::assertStringContainsString( + 'All system dependencies are already installed.', + $this->io->getOutput(), + ); + } + + public function testMissingDependencyThatDoesNotHaveAnyPackageManagerDefinition(): void + { + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([]), + PackageManager::Test, + $this->io, + ); + + $request = new RequestedPackageAndVersion('foo/foo', null); + $composerPackage = new CompletePackage('foo/foo', '1.0.0.0', '1.0.0'); + $piePackage = Package::fromComposerCompletePackage($composerPackage); + $this->dependencyResolver->expects(self::once()) + ->method('__invoke') + ->with($this->composer, $this->targetPlatform, $request, true) + ->willReturn($piePackage); + + $versionParser = new VersionParser(); + + $this->fetchDependencyStatuses->expects(self::once()) + ->method('__invoke') + ->with($this->targetPlatform, $this->composer, $composerPackage) + ->willReturn([ + new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), + ]); + + ($scanner)($this->composer, $this->targetPlatform, $request, true); + + $outputString = $this->io->getOutput(); + self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); + self::assertStringContainsString('Could not automatically install "lib-bar", as PIE does not have the package manager definition.', $outputString); + self::assertStringContainsString('No system dependencies could be installed automatically by PIE.', $outputString); + } + + public function testMissingDependencyThatDoesNotHaveMyPackageManagerDefinition(): void + { + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([ + 'bar' => [ + PackageManager::Apt->value => 'libbar-dev', + PackageManager::Apk->value => 'libbar-dev', + ], + ]), + PackageManager::Test, + $this->io, + ); + + $request = new RequestedPackageAndVersion('foo/foo', null); + $composerPackage = new CompletePackage('foo/foo', '1.0.0.0', '1.0.0'); + $piePackage = Package::fromComposerCompletePackage($composerPackage); + $this->dependencyResolver->expects(self::once()) + ->method('__invoke') + ->with($this->composer, $this->targetPlatform, $request, true) + ->willReturn($piePackage); + + $versionParser = new VersionParser(); + + $this->fetchDependencyStatuses->expects(self::once()) + ->method('__invoke') + ->with($this->targetPlatform, $this->composer, $composerPackage) + ->willReturn([ + new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), + ]); + + ($scanner)($this->composer, $this->targetPlatform, $request, true); + + $outputString = $this->io->getOutput(); + self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); + self::assertStringContainsString('Could not automatically install "lib-bar", as PIE does not have a definition for "test"', $outputString); + self::assertStringContainsString('No system dependencies could be installed automatically by PIE.', $outputString); + } + + public function testMissingDependenciesFailToInstall(): void + { + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([ + 'bar' => [ + PackageManager::Apk->value => 'hopefully-this-package-does-not-exist-in-apk', + PackageManager::Test->value => 'libbar-dev', + ], + ]), + PackageManager::Apk, + $this->io, + ); + + $request = new RequestedPackageAndVersion('foo/foo', null); + $composerPackage = new CompletePackage('foo/foo', '1.0.0.0', '1.0.0'); + $piePackage = Package::fromComposerCompletePackage($composerPackage); + $this->dependencyResolver->expects(self::once()) + ->method('__invoke') + ->with($this->composer, $this->targetPlatform, $request, true) + ->willReturn($piePackage); + + $versionParser = new VersionParser(); + + $this->fetchDependencyStatuses->expects(self::once()) + ->method('__invoke') + ->with($this->targetPlatform, $this->composer, $composerPackage) + ->willReturn([ + new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), + ]); + + ($scanner)($this->composer, $this->targetPlatform, $request, true); + + $outputString = $this->io->getOutput(); + self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); + self::assertStringContainsString('Failed to install missing system dependencies', $outputString); + } + + public function testMissingDependenciesAreSuccessfullyInstalled(): void + { + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([ + 'bar' => [ + PackageManager::Apt->value => 'libbar-dev', + PackageManager::Apk->value => 'libbar-dev', + PackageManager::Test->value => 'libbar-dev', + ], + ]), + PackageManager::Test, + $this->io, + ); + + $request = new RequestedPackageAndVersion('foo/foo', null); + $composerPackage = new CompletePackage('foo/foo', '1.0.0.0', '1.0.0'); + $piePackage = Package::fromComposerCompletePackage($composerPackage); + $this->dependencyResolver->expects(self::once()) + ->method('__invoke') + ->with($this->composer, $this->targetPlatform, $request, true) + ->willReturn($piePackage); + + $versionParser = new VersionParser(); + + $this->fetchDependencyStatuses->expects(self::once()) + ->method('__invoke') + ->with($this->targetPlatform, $this->composer, $composerPackage) + ->willReturn([ + new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), + ]); + + ($scanner)($this->composer, $this->targetPlatform, $request, true); + + $outputString = $this->io->getOutput(); + self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); + self::assertStringContainsString('Adding test package libbar-dev to be installed for lib-bar', $outputString); + self::assertStringContainsString('Need to install missing system dependencies: echo "fake installing libbar-dev"', $outputString); + } + + public function testMissingDependenciesAreNotInstalledWhenShouldNotAutoInstallAndNonInteractive(): void + { + $scanner = new PrescanSystemDependencies( + $this->dependencyResolver, + $this->fetchDependencyStatuses, + new SystemDependenciesDefinition([ + 'bar' => [ + PackageManager::Apt->value => 'libbar-dev', + PackageManager::Apk->value => 'libbar-dev', + PackageManager::Test->value => 'libbar-dev', + ], + ]), + PackageManager::Test, + $this->io, + ); + + $request = new RequestedPackageAndVersion('foo/foo', null); + $composerPackage = new CompletePackage('foo/foo', '1.0.0.0', '1.0.0'); + $piePackage = Package::fromComposerCompletePackage($composerPackage); + $this->dependencyResolver->expects(self::once()) + ->method('__invoke') + ->with($this->composer, $this->targetPlatform, $request, true) + ->willReturn($piePackage); + + $versionParser = new VersionParser(); + + $this->fetchDependencyStatuses->expects(self::once()) + ->method('__invoke') + ->with($this->targetPlatform, $this->composer, $composerPackage) + ->willReturn([ + new DependencyStatus('lib-bar', $versionParser->parseConstraints('^1.0'), null), + ]); + + ($scanner)($this->composer, $this->targetPlatform, $request, false); + + $outputString = $this->io->getOutput(); + self::assertStringContainsString('Extension foo/foo has unmet dependencies: lib-bar', $outputString); + self::assertStringContainsString('Adding test package libbar-dev to be installed for lib-bar', $outputString); + self::assertStringContainsString('You are not running in interactive mode, and you did not provide the --auto-install-system-dependencies flag.', $outputString); + self::assertStringContainsString('You may need to run: echo "fake installing libbar-dev"', $outputString); + self::assertStringNotContainsString('Need to install missing system dependencies', $outputString); + } +} diff --git a/test/unit/DependencyResolver/DependencyStatusTest.php b/test/unit/DependencyResolver/DependencyStatusTest.php new file mode 100644 index 00000000..5010d468 --- /dev/null +++ b/test/unit/DependencyResolver/DependencyStatusTest.php @@ -0,0 +1,45 @@ +', '2.0.0'), null); + self::assertSame('foo: > 2.0.0 ' . Emoji::PROHIBITED . ' (not installed)', $dependencyStatus->asPrettyString()); + self::assertFalse($dependencyStatus->satisfied()); + } + + public function testDependencyInstalledAndMatchesAllConstraint(): void + { + $dependencyStatus = new DependencyStatus('foo', new MatchAllConstraint(), new Constraint('=', '1.0.0.0')); + self::assertSame('foo: * ' . Emoji::GREEN_CHECKMARK, $dependencyStatus->asPrettyString()); + self::assertTrue($dependencyStatus->satisfied()); + } + + public function testDependencyInstalledAndMatchesSemverConstraint(): void + { + $dependencyStatus = new DependencyStatus('foo', (new VersionParser())->parseConstraints('^1.0'), new Constraint('=', '1.0.0.0')); + self::assertSame('foo: ^1.0 ' . Emoji::GREEN_CHECKMARK, $dependencyStatus->asPrettyString()); + self::assertTrue($dependencyStatus->satisfied()); + } + + public function testDependencyInstalledButMismatchingVersion(): void + { + $dependencyStatus = new DependencyStatus('foo', new Constraint('>', '2.0.0'), new Constraint('=', '1.2.3.0')); + self::assertSame('foo: > 2.0.0 ' . Emoji::PROHIBITED . ' (your version is 1.2.3.0)', $dependencyStatus->asPrettyString()); + self::assertFalse($dependencyStatus->satisfied()); + } +} diff --git a/test/unit/DependencyResolver/FetchDependencyStatusesTest.php b/test/unit/DependencyResolver/FetchDependencyStatusesTest.php new file mode 100644 index 00000000..bcd6a9c6 --- /dev/null +++ b/test/unit/DependencyResolver/FetchDependencyStatusesTest.php @@ -0,0 +1,52 @@ +createMock(Composer::class), $package)); + } + + public function testRequiresReturnsListOfStatuses(): void + { + $php = PhpBinaryPath::fromCurrentProcess(); + + $package = new CompletePackage('vendor/foo', '1.2.3.0', '1.2.3'); + $package->setRequires([ + 'ext-core' => new Link('__root__', 'ext-core', new Constraint('=', $php->version() . '.0')), + 'ext-nonsense_extension' => new Link('__root__', 'ext-nonsense_extension', new Constraint('=', '*')), + 'ext-standard' => new Link('__root__', 'ext-standard', new Constraint('<', '1.0.0.0')), + ]); + + $deps = (new FetchDependencyStatuses())( + TargetPlatform::fromPhpBinaryPath($php, null, null), + Factory::create($this->createMock(IOInterface::class)), + $package, + ); + + self::assertCount(3, $deps); + + self::assertSame('ext-core: == ' . $php->version() . '.0 ✅', $deps[0]->asPrettyString()); + self::assertSame('ext-nonsense_extension: == * 🚫 (not installed)', $deps[1]->asPrettyString()); + self::assertSame('ext-standard: < 1.0.0.0 🚫 (your version is ' . $php->version() . '.0)', $deps[2]->asPrettyString()); + } +} diff --git a/test/unit/Platform/InstalledPiePackagesTest.php b/test/unit/Platform/InstalledPiePackagesTest.php index 22e6357a..b273cd2a 100644 --- a/test/unit/Platform/InstalledPiePackagesTest.php +++ b/test/unit/Platform/InstalledPiePackagesTest.php @@ -39,4 +39,22 @@ public function testAllPiePackages(): void self::assertSame('bar2', $packages['bar2']->extensionName()->name()); self::assertSame('foo/bar2', $packages['bar2']->name()); } + + public function testInvalidExtensionNamesAreFilteredOut(): void + { + $localRepo = $this->createMock(InstalledRepositoryInterface::class); + $localRepo->method('getPackages')->willReturn([ + new CompletePackage('foo/invalid-extension-name', '1.2.3.0', '1.2.3'), + new CompletePackage('invalid-extension-name', '1.2.3.0', '1.2.3'), + new CompletePackage('invalid_extension_name', '1.2.3.0', '1.2.3'), + ]); + + $repoManager = $this->createMock(RepositoryManager::class); + $repoManager->method('getLocalRepository')->willReturn($localRepo); + + $composer = $this->createMock(Composer::class); + $composer->method('getRepositoryManager')->willReturn($repoManager); + + self::assertCount(0, (new InstalledPiePackages())->allPiePackages($composer)); + } } diff --git a/test/unit/SelfManage/BuildTools/PackageManagerTest.php b/test/unit/Platform/PackageManagerTest.php similarity index 92% rename from test/unit/SelfManage/BuildTools/PackageManagerTest.php rename to test/unit/Platform/PackageManagerTest.php index f66d490f..87c72d92 100644 --- a/test/unit/SelfManage/BuildTools/PackageManagerTest.php +++ b/test/unit/Platform/PackageManagerTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Php\PieUnitTest\SelfManage\BuildTools; +namespace Php\PieUnitTest\Platform; -use Php\Pie\SelfManage\BuildTools\PackageManager; +use Php\Pie\Platform\PackageManager; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; diff --git a/test/unit/Platform/TargetPhp/PhpBinaryPathTest.php b/test/unit/Platform/TargetPhp/PhpBinaryPathTest.php index eeeda371..4bd9a668 100644 --- a/test/unit/Platform/TargetPhp/PhpBinaryPathTest.php +++ b/test/unit/Platform/TargetPhp/PhpBinaryPathTest.php @@ -400,10 +400,8 @@ public function testDifferentVersionsOfPhp(string $phpPath): void $php = PhpBinaryPath::fromPhpBinaryPath($phpPath); self::assertArrayHasKey('Core', $php->extensions()); self::assertNotEmpty($php->extensionPath()); - self::assertInstanceOf(OperatingSystem::class, $php->operatingSystem()); self::assertNotEmpty($php->version()); self::assertNotEmpty($php->majorMinorVersion()); - self::assertInstanceOf(Architecture::class, $php->machineType()); self::assertGreaterThan(0, $php->phpIntSize()); self::assertNotEmpty($php->phpinfo()); } diff --git a/test/unit/SelfManage/BuildTools/BinaryBuildToolFinderTest.php b/test/unit/SelfManage/BuildTools/BinaryBuildToolFinderTest.php index c5b63096..f1c52f9d 100644 --- a/test/unit/SelfManage/BuildTools/BinaryBuildToolFinderTest.php +++ b/test/unit/SelfManage/BuildTools/BinaryBuildToolFinderTest.php @@ -4,10 +4,10 @@ namespace Php\PieUnitTest\SelfManage\BuildTools; +use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPhp\PhpBinaryPath; use Php\Pie\Platform\TargetPlatform; use Php\Pie\SelfManage\BuildTools\BinaryBuildToolFinder; -use Php\Pie\SelfManage\BuildTools\PackageManager; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; diff --git a/test/unit/SelfManage/BuildTools/CheckAllBuildToolsTest.php b/test/unit/SelfManage/BuildTools/CheckAllBuildToolsTest.php index 07a479f4..c8e89f40 100644 --- a/test/unit/SelfManage/BuildTools/CheckAllBuildToolsTest.php +++ b/test/unit/SelfManage/BuildTools/CheckAllBuildToolsTest.php @@ -8,12 +8,12 @@ use Php\Pie\Platform\Architecture; use Php\Pie\Platform\OperatingSystem; use Php\Pie\Platform\OperatingSystemFamily; +use Php\Pie\Platform\PackageManager; use Php\Pie\Platform\TargetPhp\PhpBinaryPath; use Php\Pie\Platform\TargetPlatform; use Php\Pie\Platform\ThreadSafetyMode; use Php\Pie\SelfManage\BuildTools\BinaryBuildToolFinder; use Php\Pie\SelfManage\BuildTools\CheckAllBuildTools; -use Php\Pie\SelfManage\BuildTools\PackageManager; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Output\OutputInterface;