diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 239a0d3e..512273e4 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -95,6 +95,7 @@ jobs: libsasl2-dev \ libpq-dev \ libsqlite3-dev \ + libbz2-dev \ libzip-dev - name: "Set php-src download URL" run: echo "php_src_download_url=https://www.php.net/distributions/php-${{ matrix.php-versions }}.tar.gz" >> $GITHUB_ENV diff --git a/src/ComposerIntegration/BundledPhpExtensionsRepository.php b/src/ComposerIntegration/BundledPhpExtensionsRepository.php index b2f54563..bfeae8b1 100644 --- a/src/ComposerIntegration/BundledPhpExtensionsRepository.php +++ b/src/ComposerIntegration/BundledPhpExtensionsRepository.php @@ -43,7 +43,10 @@ class BundledPhpExtensionsRepository extends ArrayRepository ['name' => 'bz2'], ['name' => 'calendar'], ['name' => 'ctype'], - ['name' => 'curl'], + [ + 'name' => 'curl', + 'require' => ['lib-curl' => '*'], + ], ['name' => 'dba'], [ 'name' => 'dom', @@ -59,7 +62,10 @@ class BundledPhpExtensionsRepository extends ArrayRepository ['name' => 'exif'], [ 'name' => 'ffi', - 'require' => ['php' => '>= 7.4.0'], + 'require' => [ + 'php' => '>= 7.4.0', + 'lib-ffi' => '*', + ], ], // ['name' => 'gd'], // build failure - ext/gd/gd.c:79:11: fatal error: ft2build.h: No such file or directory ['name' => 'gettext'], @@ -148,7 +154,10 @@ class BundledPhpExtensionsRepository extends ArrayRepository ['name' => 'sockets'], [ 'name' => 'sodium', - 'require' => ['php' => '>= 7.2.0'], + 'require' => [ + 'php' => '>= 7.2.0', + 'lib-sodium' => '*', + ], ], [ 'name' => 'sqlite3', @@ -185,11 +194,15 @@ class BundledPhpExtensionsRepository extends ArrayRepository 'require' => [ 'php' => '>= 5.2.0', 'ext-libxml' => '*', + 'lib-xslt' => '*', ], ], [ 'name' => 'zip', - 'require' => ['php' => '>= 5.2.0'], + 'require' => [ + 'php' => '>= 5.2.0', + 'lib-zip' => '*', + ], ], ['name' => 'zlib'], ]; diff --git a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php index 1a4085d4..a73bfaec 100644 --- a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php +++ b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php @@ -12,8 +12,11 @@ use Php\Pie\ExtensionName; use Php\Pie\Platform\InstalledPiePackages; use Php\Pie\Platform\TargetPhp\PhpBinaryPath; +use Php\Pie\Util\Process; +use Symfony\Component\Process\Exception\ProcessFailedException; use UnexpectedValueException; +use function explode; use function in_array; use function str_replace; use function str_starts_with; @@ -77,6 +80,8 @@ public function __construct(PhpBinaryPath $phpBinaryPath, Composer $composer, In $this->addPackage($this->packageForExtension($extension, $extensionVersion)); } + $this->addLibrariesUsingPkgConfig(); + parent::__construct(); } @@ -107,4 +112,65 @@ private function packageForExtension(string $name, string $prettyVersion): Compl return $package; } + + /** + * The `$alias` parameter is the name of the dependency in `composer.json`, + * but without the `lib-` prefix; e.g. `curl` would be `lib-curl` in the + * `composer.json`. + * + * The `$library` parameter should be the name of the library to look up + * using `pkg-config`. + */ + private function detectLibraryWithPkgConfig(string $alias, string $library): void + { + try { + $pkgConfigResult = Process::run(['pkg-config', '--print-provides', '--print-errors', $library], timeout: 10); + } catch (ProcessFailedException) { + return; + } + + [$library, $prettyVersion] = explode('=', $pkgConfigResult); + if (! $library || ! $prettyVersion) { + return; + } + + try { + $version = $this->versionParser->normalize($prettyVersion); + } catch (UnexpectedValueException) { + $version = '*'; // @todo check this is the best way to handle unparsed versions? + } + + $lib = new CompletePackage('lib-' . $alias, $version, $prettyVersion); + $lib->setDescription('The ' . $alias . ' library, ' . $library); + $this->addPackage($lib); + } + + private function addLibrariesUsingPkgConfig(): void + { + $this->detectLibraryWithPkgConfig('curl', 'libcurl'); + $this->detectLibraryWithPkgConfig('enchant', 'enchant'); + $this->detectLibraryWithPkgConfig('enchant-2', 'enchant-2'); + $this->detectLibraryWithPkgConfig('sodium', 'libsodium'); + $this->detectLibraryWithPkgConfig('ffi', 'libffi'); + $this->detectLibraryWithPkgConfig('xslt', 'libxslt'); + $this->detectLibraryWithPkgConfig('zip', 'libzip'); + $this->detectLibraryWithPkgConfig('png', 'libpng'); + $this->detectLibraryWithPkgConfig('avif', 'libavif'); + $this->detectLibraryWithPkgConfig('webp', 'libwebp'); + $this->detectLibraryWithPkgConfig('jpeg', 'libjpeg'); + $this->detectLibraryWithPkgConfig('xpm', 'xpm'); + $this->detectLibraryWithPkgConfig('freetype2', 'freetype2'); + $this->detectLibraryWithPkgConfig('gdlib', 'gdlib'); + $this->detectLibraryWithPkgConfig('gmp', 'gmp'); + $this->detectLibraryWithPkgConfig('sasl', 'libsasl2'); + $this->detectLibraryWithPkgConfig('onig', 'oniguruma'); + $this->detectLibraryWithPkgConfig('odbc', 'libiodbc'); + $this->detectLibraryWithPkgConfig('capstone', 'capstone'); + $this->detectLibraryWithPkgConfig('pcre', 'libpcre2-8'); + $this->detectLibraryWithPkgConfig('edit', 'libedit'); + $this->detectLibraryWithPkgConfig('snmp', 'netsnmp'); + $this->detectLibraryWithPkgConfig('argon2', 'libargon2'); + $this->detectLibraryWithPkgConfig('uriparser', 'liburiparser'); + $this->detectLibraryWithPkgConfig('exslt', 'libexslt'); + } } diff --git a/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php b/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php index 1bc77d9d..9d1f593f 100644 --- a/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php +++ b/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php @@ -9,15 +9,23 @@ use Composer\Package\Link; use Composer\Package\PackageInterface; use Composer\Semver\Constraint\Constraint; +use Composer\Util\Platform; use Php\Pie\ComposerIntegration\PhpBinaryPathBasedPlatformRepository; use Php\Pie\DependencyResolver\Package; use Php\Pie\ExtensionName; use Php\Pie\Platform\InstalledPiePackages; use Php\Pie\Platform\TargetPhp\PhpBinaryPath; +use Php\Pie\Util\Process; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Symfony\Component\Process\Exception\ProcessFailedException; +use function array_combine; +use function array_filter; use function array_map; +use function in_array; +use function str_starts_with; #[CoversClass(PhpBinaryPathBasedPlatformRepository::class)] final class PhpBinaryPathBasedPlatformRepositoryTest extends TestCase @@ -54,7 +62,10 @@ public function testPlatformRepositoryContainsExpectedPacakges(): void ], array_map( static fn (PackageInterface $package): string => $package->getName() . ':' . $package->getPrettyVersion(), - $platformRepository->getPackages(), + array_filter( + $platformRepository->getPackages(), + static fn (PackageInterface $package): bool => ! str_starts_with($package->getName(), 'lib-'), + ), ), ); } @@ -88,7 +99,10 @@ public function testPlatformRepositoryExcludesExtensionBeingInstalled(): void ], array_map( static fn (PackageInterface $package): string => $package->getName() . ':' . $package->getPrettyVersion(), - $platformRepository->getPackages(), + array_filter( + $platformRepository->getPackages(), + static fn (PackageInterface $package): bool => ! str_starts_with($package->getName(), 'lib-'), + ), ), ); } @@ -128,8 +142,91 @@ public function testPlatformRepositoryExcludesReplacedExtensions(): void ], array_map( static fn (PackageInterface $package): string => $package->getName() . ':' . $package->getPrettyVersion(), - $platformRepository->getPackages(), + array_filter( + $platformRepository->getPackages(), + static fn (PackageInterface $package): bool => ! str_starts_with($package->getName(), 'lib-'), + ), ), ); } + + /** @return array */ + public static function installedLibraries(): array + { + // data providers cannot return empty, even if the test is skipped + if (Platform::isWindows()) { + return ['skip' => ['skip']]; + } + + $installedLibs = array_filter( + [ + ['curl', 'libcurl'], + ['enchant', 'enchant'], + ['enchant-2', 'enchant-2'], + ['sodium', 'libsodium'], + ['ffi', 'libffi'], + ['xslt', 'libxslt'], + ['zip', 'libzip'], + ['png', 'libpng'], + ['avif', 'libavif'], + ['webp', 'libwebp'], + ['jpeg', 'libjpeg'], + ['xpm', 'xpm'], + ['freetype2', 'freetype2'], + ['gdlib', 'gdlib'], + ['gmp', 'gmp'], + ['sasl', 'libsasl2'], + ['onig', 'oniguruma'], + ['odbc', 'libiodbc'], + ['capstone', 'capstone'], + ['pcre', 'libpcre2-8'], + ['edit', 'libedit'], + ['snmp', 'netsnmp'], + ['argon2', 'libargon2'], + ['uriparser', 'liburiparser'], + ['exslt', 'libexslt'], + ], + static function (array $pkg): bool { + try { + Process::run(['pkg-config', '--print-provides', '--print-errors', $pkg[1]], timeout: 10); + + return true; + } catch (ProcessFailedException) { + return false; + } + }, + ); + + return array_combine( + array_map( + static fn (array $pkg): string => $pkg[0], + $installedLibs, + ), + array_map( + static fn (array $pkg): array => [$pkg[0]], + $installedLibs, + ), + ); + } + + #[DataProvider('installedLibraries')] + public function testLibrariesAreIncluded(string $packageName): void + { + if (Platform::isWindows()) { + self::markTestSkipped('pkg-config not available on Windows'); + } + + self::assertTrue(in_array( + 'lib-' . $packageName, + array_map( + static fn (PackageInterface $package): string => $package->getName(), + (new PhpBinaryPathBasedPlatformRepository( + PhpBinaryPath::fromCurrentProcess(), + $this->createMock(Composer::class), + $this->createMock(InstalledPiePackages::class), + ExtensionName::normaliseFromString('extension_being_installed'), + ))->getPackages(), + ), + )); + } }