diff --git a/docs/LIBPHP_INSTALLER.md b/docs/LIBPHP_INSTALLER.md index 6ae7d21a..483be4a9 100644 --- a/docs/LIBPHP_INSTALLER.md +++ b/docs/LIBPHP_INSTALLER.md @@ -1,6 +1,8 @@ # 自动构建 libphp.so -TypePHP 的可执行文件和共享库模式需要 PHP Embed SAPI 提供的 `libphp.so`。许多 Linux 发行版的 PHP 包只包含 CLI 或 FPM,因此 `tpc.php` 在找不到 `libphp.so` 时会询问是否自动构建一份私有 PHP。 +TypePHP 的可执行文件和共享库模式需要 PHP Embed SAPI 提供的 `libphp.so`。许多 Linux 发行版的 PHP 包只包含 CLI 或 FPM,因此 Composer 安装的 `tpc.php` 在找不到 `libphp.so` 时会询问是否自动构建一份私有 PHP。 + +该安装器面向由 PHP 解释器启动的 `tpc.php`。自举二进制 `tpc` 在进入 `main()` 前就必须由系统动态链接器加载 `libphp.so` 和 `libphpx.so`,不能依靠自身补装缺失库。 该功能只在 Linux 和交互式终端中启用。扩展模式(`-m ext`)不需要 `libphp.so`,不会触发安装器;CI 等非交互环境也不会自动下载、安装软件包或执行 `sudo`。 @@ -15,7 +17,7 @@ vendor/bin/tpc.php project.yml 缺少 `libphp.so` 时,安装器会依次询问: 1. 是否自动构建 PHP Embed 库; -2. PHP 版本,默认从 PHP.net 获取最新稳定版 PHP 8.4,也可输入 PHP 8.4/8.5 的具体稳定版本; +2. PHP 版本,默认与当前运行 `tpc.php` 的 `PHP_VERSION` 完全一致,也可手动输入其他 PHP 8.4.x/8.5.x 稳定版本; 3. 安装目录,默认为 `~/.typephp`; 4. 是否通过检测到的 `apt-get`、`dnf` 或 `yum` 安装缺失的开发包。 @@ -49,3 +51,17 @@ vendor/bin/tpc.php project.yml ```bash PHP_HOME=/path/to/php vendor/bin/tpc.php project.yml ``` + +## 自动构建 libphpx.so + +Linux 下完成 PHP Embed 检查后,构建流水线还会检查 PHPX 根目录中的 `lib/libphpx.so`。PHPX 根目录按以下顺序解析: + +1. `PHPX_HOME`; +2. Composer `InstalledVersions` 中的 `swoole/phpx` 安装路径; +3. TypePHP 源码仓库内的 `vendor/swoole/phpx`。 + +缺少共享库时,交互式终端会询问是否构建。PHPX 本身不依赖 `libphp.so`;它依赖 PHP 头文件和 `php-config`。`LibPhpxInstaller` 使用当前选定的 PHP 前缀,同时设置 `PHP_HOME`、将该前缀的 `bin` 放到 `PATH` 首位,并向 PHPX CMake 传递 `-Dphp_dir=`,确保 PHPX 与项目运行时使用的 PHP ABI 一致。 + +工具链检测在本地库检查之后执行。用户确认自动构建后,安装器通过 `apt-get`、`dnf` 或 `yum` 检查并可安装 GCC/G++、make、CMake、pkg-config 以及 PHP configure 所需依赖;因此 Composer 环境无需预先安装完整 C/C++ 工具链。 + +构建固定使用 Release、关闭 PHPX tests,并只构建 `phpx` target;并行度最多为 8。输出必须为 `/lib/libphpx.so`,否则安装器报错。非交互环境只提示缺失,不执行 CMake。 diff --git a/phpunit/src/Installer/LibPhpxInstallerTest.php b/phpunit/src/Installer/LibPhpxInstallerTest.php new file mode 100644 index 00000000..9a4b2ac8 --- /dev/null +++ b/phpunit/src/Installer/LibPhpxInstallerTest.php @@ -0,0 +1,29 @@ +hasLibPhpx($root)); + touch($root . '/lib/libphpx.a'); + self::assertFalse($installer->hasLibPhpx($root)); + touch($root . '/lib/libphpx.so'); + self::assertTrue($installer->hasLibPhpx($root . '/')); + } finally { + @unlink($root . '/lib/libphpx.so'); + @unlink($root . '/lib/libphpx.a'); + @rmdir($root . '/lib'); + @rmdir($root); + } + } +} diff --git a/phpunit/src/Installer/PhpBuildConfigurationTest.php b/phpunit/src/Installer/PhpBuildConfigurationTest.php index 9ff56fd8..049eabd2 100644 --- a/phpunit/src/Installer/PhpBuildConfigurationTest.php +++ b/phpunit/src/Installer/PhpBuildConfigurationTest.php @@ -37,11 +37,24 @@ final class PhpBuildConfigurationTest extends TestCase $packages = $manager->packagesForConfigureOptions(['--with-curl', '--enable-mbstring']); self::assertContains('build-essential', $packages); + self::assertContains('cmake', $packages); self::assertContains('libcurl4-openssl-dev', $packages); self::assertContains('libonig-dev', $packages); self::assertNotContains('libzip-dev', $packages); } + public function testBuildToolPackagesIncludeCompilerMakeAndCmake(): void + { + self::assertSame( + ['build-essential', 'cmake', 'pkg-config'], + (new LinuxPackageManager('apt-get'))->packagesForBuildTools() + ); + self::assertSame( + ['gcc', 'gcc-c++', 'make', 'cmake', 'pkgconf-pkg-config'], + (new LinuxPackageManager('dnf'))->packagesForBuildTools() + ); + } + public function testMissingPackagesFiltersInstalledPackages(): void { $manager = new LinuxPackageManager('apt-get'); diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index 20f5dac5..6666ef43 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -12,6 +12,7 @@ use TypePhp\Backend\CompilerFactory; use TypePhp\Exception\SyntaxError; use TypePhp\Exception\Unsupported; use TypePhp\Installer\LibPhpInstaller; +use TypePhp\Installer\LibPhpxInstaller; use TypePhp\Platform\Linux; trait SourcePipelineTrait @@ -64,10 +65,20 @@ trait SourcePipelineTrait if ($this->isBuildModeEmbed() && $this->getPlatform() instanceof Linux) { try { - (new LibPhpInstaller())->ensure($this->getPhpDir()); + $phpDir = (new LibPhpInstaller())->ensure($this->getPhpDir()) ?? $this->getPhpDir(); } catch (\Throwable $e) { $this->error('Unable to install libphp.so: ' . $e->getMessage()); } + } else { + $phpDir = $this->getPhpDir(); + } + + if ($this->getPlatform() instanceof Linux) { + try { + (new LibPhpxInstaller())->ensure($this->getPhpxDir(), $phpDir); + } catch (\Throwable $e) { + $this->error('Unable to build libphpx.so: ' . $e->getMessage()); + } } $this->validateCompilerToolchain(); diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 75b21849..2d8f90dd 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -486,7 +486,18 @@ class CompilerBase implements PropertyAccessContext return rtrim($phpxDir, '\/'); } - // 尝试使用 Composer 安装的 phpx + // Composer-installed TypePHP and PHPX are sibling packages. Ask + // Composer for the actual package path instead of assuming a nested + // vendor directory inside the TypePHP package. + if (class_exists(\Composer\InstalledVersions::class) + && \Composer\InstalledVersions::isInstalled('swoole/phpx')) { + $composerInstallPath = \Composer\InstalledVersions::getInstallPath('swoole/phpx'); + if (is_string($composerInstallPath) && is_dir($composerInstallPath)) { + return rtrim($composerInstallPath, '\/'); + } + } + + // TypePHP source checkout: phpx is installed below this repository. $composerPhpxDir = $this->rootPath . self::PHPX_VENDOR_DIR; if (is_dir($composerPhpxDir)) { return $composerPhpxDir; diff --git a/src/Installer/LibPhpInstaller.php b/src/Installer/LibPhpInstaller.php index fa75a233..a894cba5 100644 --- a/src/Installer/LibPhpInstaller.php +++ b/src/Installer/LibPhpInstaller.php @@ -27,14 +27,12 @@ final class LibPhpInstaller return null; } - $release = $this->latestRelease(); - $version = $this->console->ask("PHP version [{$release['version']}]: ", $release['version']); + $defaultVersion = PHP_VERSION; + $version = $this->console->ask("PHP version [{$defaultVersion}]: ", $defaultVersion); if (!preg_match('/^8\.[45]\.\d+$/', $version)) { throw new \RuntimeException('Only stable PHP 8.4.x and 8.5.x versions are supported by the automatic installer'); } - if ($version !== $release['version']) { - $release = $this->release($version); - } + $release = $this->release($version); $home = getenv('HOME') ?: (string) ($_SERVER['HOME'] ?? ''); $defaultPrefix = rtrim($home, '/') . '/.typephp'; diff --git a/src/Installer/LibPhpxInstaller.php b/src/Installer/LibPhpxInstaller.php new file mode 100644 index 00000000..9ac3729f --- /dev/null +++ b/src/Installer/LibPhpxInstaller.php @@ -0,0 +1,124 @@ +hasLibPhpx($phpxDir)) { + return $phpxDir; + } + if (!$this->console->isInteractive()) { + $this->console->write('libphpx.so is missing. Run tpc.php in an interactive terminal to build it automatically, or set PHPX_HOME.'); + return null; + } + + $this->validateSourceTree($phpxDir); + $this->validatePhpInstallation($phpDir); + $this->console->write("The PHPX installation does not provide libphpx.so: {$phpxDir}"); + if (!$this->console->confirm('Build libphpx.so now?', true)) { + return null; + } + + $this->installBuildDependencies($phpDir); + $buildDir = $phpxDir . '/build'; + $this->mkdir($buildDir); + $this->run([ + 'cmake', + '-S', $phpxDir, + '-B', $buildDir, + '-DCMAKE_BUILD_TYPE=Release', + '-DBUILD_TESTS=OFF', + '-Dphp_dir=' . $phpDir, + ], $phpDir); + $this->run([ + 'cmake', + '--build', $buildDir, + '--parallel', (string) min(8, max(1, $this->cpuCount())), + '--target', 'phpx', + ], $phpDir); + + if (!$this->hasLibPhpx($phpxDir)) { + throw new \RuntimeException("Build completed but {$phpxDir}/lib/libphpx.so was not created"); + } + $this->console->write("libphpx.so built successfully in {$phpxDir}/lib"); + return $phpxDir; + } + + public function hasLibPhpx(string $phpxDir): bool + { + return is_file(rtrim($phpxDir, '/') . '/lib/libphpx.so'); + } + + private function validateSourceTree(string $phpxDir): void + { + if (!is_file($phpxDir . '/CMakeLists.txt') || !is_dir($phpxDir . '/src') || !is_dir($phpxDir . '/include')) { + throw new \RuntimeException("PHPX source tree is incomplete: {$phpxDir}"); + } + } + + private function validatePhpInstallation(string $phpDir): void + { + $php = $phpDir . '/bin/php'; + $phpConfig = $phpDir . '/bin/php-config'; + if (!is_executable($php) || !is_executable($phpConfig)) { + throw new \RuntimeException("PHP installation must provide bin/php and bin/php-config: {$phpDir}"); + } + } + + private function installBuildDependencies(string $phpDir): void + { + $manager = LinuxPackageManager::detect(); + if ($manager === null) { + $this->console->write('No supported package manager (apt-get/dnf/yum) was found; continuing with existing build tools.'); + return; + } + $packages = $manager->missingPackages($manager->packagesForBuildTools()); + $this->console->write('Detected package manager: ' . $manager->command); + if ($packages === []) { + $this->console->write('All detected PHPX build dependencies are already installed.'); + return; + } + if (!$this->console->confirm('Install missing PHPX build dependencies (' . implode(', ', $packages) . ')?', true)) { + return; + } + $useSudo = function_exists('posix_geteuid') && posix_geteuid() !== 0; + $refresh = $manager->refreshCommand($useSudo); + if ($refresh !== null) { + $this->run($refresh, $phpDir); + } + $this->run($manager->installCommand($packages, $useSudo), $phpDir); + } + + private function run(array $command, string $phpDir): void + { + $this->console->write('$ ' . implode(' ', array_map('escapeshellarg', $command))); + $environment = getenv(); + $environment['PHP_HOME'] = $phpDir; + $environment['PATH'] = $phpDir . '/bin:' . ($environment['PATH'] ?? ''); + $process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, null, $environment); + if (!is_resource($process) || proc_close($process) !== 0) { + throw new \RuntimeException('Command failed: ' . implode(' ', $command)); + } + } + + private function mkdir(string $directory): void + { + if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { + throw new \RuntimeException("Unable to create directory {$directory}"); + } + } + + private function cpuCount(): int + { + $count = (int) trim((string) shell_exec('getconf _NPROCESSORS_ONLN 2>/dev/null')); + return $count > 0 ? $count : 1; + } +} diff --git a/src/Installer/LinuxPackageManager.php b/src/Installer/LinuxPackageManager.php index 9c9c5b00..a406ec03 100644 --- a/src/Installer/LinuxPackageManager.php +++ b/src/Installer/LinuxPackageManager.php @@ -29,9 +29,9 @@ final readonly class LinuxPackageManager $text = implode(' ', $options); $groups = [ 'base' => [ - 'apt-get' => ['build-essential', 'pkg-config', 'autoconf', 'bison', 're2c', 'libxml2-dev', 'libsqlite3-dev'], - 'dnf' => ['gcc', 'gcc-c++', 'make', 'pkgconf-pkg-config', 'autoconf', 'bison', 're2c', 'libxml2-devel', 'sqlite-devel'], - 'yum' => ['gcc', 'gcc-c++', 'make', 'pkgconfig', 'autoconf', 'bison', 're2c', 'libxml2-devel', 'sqlite-devel'], + 'apt-get' => ['autoconf', 'bison', 're2c', 'libxml2-dev', 'libsqlite3-dev'], + 'dnf' => ['autoconf', 'bison', 're2c', 'libxml2-devel', 'sqlite-devel'], + 'yum' => ['autoconf', 'bison', 're2c', 'libxml2-devel', 'sqlite-devel'], ], 'curl' => ['needle' => '--with-curl', 'apt-get' => ['libcurl4-openssl-dev'], 'dnf' => ['libcurl-devel'], 'yum' => ['libcurl-devel']], 'openssl' => ['needle' => '--with-openssl', 'apt-get' => ['libssl-dev'], 'dnf' => ['openssl-devel'], 'yum' => ['openssl-devel']], @@ -52,7 +52,7 @@ final readonly class LinuxPackageManager 'gd' => ['needle' => '--enable-gd', 'apt-get' => ['libpng-dev', 'libjpeg-dev', 'libwebp-dev', 'libfreetype6-dev'], 'dnf' => ['libpng-devel', 'libjpeg-turbo-devel', 'libwebp-devel', 'freetype-devel'], 'yum' => ['libpng-devel', 'libjpeg-turbo-devel', 'libwebp-devel', 'freetype-devel']], ]; - $packages = $groups['base'][$this->command]; + $packages = [...$this->packagesForBuildTools(), ...$groups['base'][$this->command]]; unset($groups['base']); foreach ($groups as $group) { if (str_contains($text, $group['needle'])) { @@ -62,6 +62,15 @@ final readonly class LinuxPackageManager return array_values(array_unique($packages)); } + public function packagesForBuildTools(): array + { + return match ($this->command) { + 'apt-get' => ['build-essential', 'cmake', 'pkg-config'], + 'dnf' => ['gcc', 'gcc-c++', 'make', 'cmake', 'pkgconf-pkg-config'], + 'yum' => ['gcc', 'gcc-c++', 'make', 'cmake', 'pkgconfig'], + }; + } + public function installCommand(array $packages, bool $useSudo): array { $prefix = $useSudo ? ['sudo'] : []; diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index adf60e11..42cdc2f1 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -336,7 +336,7 @@ trait MethodCallTrait return $this->parseUniversalMethodCall($expr, $object, $methodName, $anyExtension, $this->isVarExpr($expr->var)); } } - // ExtensionProvider::Keyword extensions apply to every receiver type. + // ExtensionProvider('*') extensions apply to every receiver type. $kwExt = $this->findKeywordExtensionMethod($methodName); if ($kwExt) { return $this->parseUniversalMethodCall($expr, $object, $methodName, $kwExt, $this->isVarExpr($expr->var)); diff --git a/src/Platform/PlatformBase.php b/src/Platform/PlatformBase.php index 13e6ac05..b4dd6ad7 100644 --- a/src/Platform/PlatformBase.php +++ b/src/Platform/PlatformBase.php @@ -103,14 +103,14 @@ abstract class PlatformBase if (!is_file($phpDir . '/lib/libphp.' . $ext)) { $warnings[] = [ 'warning' => "The `libphp.{$ext}` is not found", - 'info' => 'Note: If you are building an extension (-m ext), this is OK. For binary mode, please run `make` to build it', + 'info' => 'Run tpc.php in an interactive terminal to build it automatically, or set PHP_HOME', ]; } if (!is_file($phpxDir . '/lib/libphpx.' . $ext)) { $warnings[] = [ 'warning' => "The `libphpx.{$ext}` is not found", - 'info' => 'Note: If you are building an extension (-m ext), this is OK. For binary mode, please run `make` to build it', + 'info' => 'Run tpc.php in an interactive terminal to build it automatically, or set PHPX_HOME', ]; } diff --git a/src/Platform/UnixPlatform.php b/src/Platform/UnixPlatform.php index e611a6db..d2753c13 100644 --- a/src/Platform/UnixPlatform.php +++ b/src/Platform/UnixPlatform.php @@ -88,6 +88,15 @@ abstract class UnixPlatform extends PlatformBase return rtrim($phpDir, '\/'); } + // Composer executes tpc.php with an already selected PHP binary. Use + // that installation before consulting an unrelated php-config from + // PATH, which may point at another PHP minor version or ABI. + $runningPhp = realpath(PHP_BINARY) ?: PHP_BINARY; + $runningPhpDir = dirname(dirname($runningPhp)); + if (is_executable($runningPhpDir . '/bin/php-config')) { + return $runningPhpDir; + } + $phpDir = shell_exec('php-config --prefix 2>/dev/null'); if (!empty($phpDir)) { return trim($phpDir); diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 4b6b4759..524b8e63 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -634,6 +634,9 @@ class Preprocessor extends CompilerBase private function parseExtensionProviderTargetValue(Node\Expr $value, NodeAbstract $errorNode): string { + if ($value instanceof Node\Scalar\String_ && $value->value === '*') { + return '*'; + } if ($value instanceof Node\Expr\ClassConstFetch && $this->isNameExpr($value->class) && $this->isIdExpr($value->name)) { @@ -659,11 +662,8 @@ class Preprocessor extends CompilerBase if (strcasecmp($class, 'Type') === 0 && isset($targets[$constant])) { return $targets[$constant]; } - if (strcasecmp($class, 'ExtensionProvider') === 0 && $constant === 'Keyword') { - return '*'; - } } - $this->fatalError($errorNode, 'ExtensionProvider target must be Type::*, ExtensionProvider::Keyword, or ClassName::class'); + $this->fatalError($errorNode, "ExtensionProvider target must be '*', Type::*, or ClassName::class"); } protected function buildLiteralArrayInitPlan(Node\Expr\Array_ $defaultNode): ArrayInitPlan diff --git a/src/polyfills.php b/src/polyfills.php index 3bf8b208..a1db4125 100644 --- a/src/polyfills.php +++ b/src/polyfills.php @@ -9,8 +9,6 @@ #[Attribute(Attribute::TARGET_CLASS)] final readonly class ExtensionProvider { - public const string Keyword = '*'; - public function __construct(public string $target) { } diff --git a/tests/compiler/keyword_extension/001.phpt b/tests/compiler/keyword_extension/001.phpt index c2466687..5227daf9 100644 --- a/tests/compiler/keyword_extension/001.phpt +++ b/tests/compiler/keyword_extension/001.phpt @@ -5,7 +5,7 @@ Keyword ExtensionProvider method with snake_case name declare(strict_types=1); use native_types; -#[ExtensionProvider(ExtensionProvider::Keyword)] +#[ExtensionProvider('*')] final class KeywordExtensions { public static function var_dump(mixed $var): void diff --git a/tests/compiler/keyword_extension/camel.phpt b/tests/compiler/keyword_extension/camel.phpt index 243061ed..3e544518 100644 --- a/tests/compiler/keyword_extension/camel.phpt +++ b/tests/compiler/keyword_extension/camel.phpt @@ -6,7 +6,7 @@ Keyword ExtensionProvider method with lowerCamelCase name declare(strict_types=1); use native_types; -#[ExtensionProvider(ExtensionProvider::Keyword)] +#[ExtensionProvider('*')] final class KeywordExtensions { public static function inspectValue(mixed $value, string $prefix): void