From 3f159a8c0518874142d664ff25c2230bf5a4c504 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sat, 19 Sep 2026 12:51:52 +0800 Subject: [PATCH] feat(compiler): implement embedded opcode compilation for bundled files - Replace resolveComposerAutoloader function with direct vendor/autoload.php path - Add anonymous class opcode embedding functionality with conditional logic - Implement bundled-files feature for packaging vendor dependencies - Create object caching system under build/cache directory structure - Add OPcache integration for generating embedded opcode blobs - Support vendor directory caching with mtime-based invalidation - Add progress reporting for opcode generation process - Move legacy build cache to new cache directory structure - Update documentation for bundled-files configuration option - Add error handling for opcode build environment requirements --- README-CN.md | 22 +- README.md | 28 +- project.yml | 3 + src/Build/IncrementalCompilationTrait.php | 1 + src/Build/NativeCommandOptionsTrait.php | 3 +- src/Build/SourcePipelineTrait.php | 550 +++++++++++++++++++++- src/Build/TranslationUnitSplitTrait.php | 22 +- src/CompilerBase.php | 21 +- src/Preprocessor.php | 66 ++- src/Translator.php | 82 +++- src/compiler.php | 39 +- 11 files changed, 771 insertions(+), 66 deletions(-) diff --git a/README-CN.md b/README-CN.md index a7efaff2..29709caf 100644 --- a/README-CN.md +++ b/README-CN.md @@ -304,6 +304,10 @@ sources: - path: src/windows if: PHP_OS_FAMILY == "Windows" +# 将文件打包到二进制,并为 ZendVM 提供字节码及内存文件读取。 +bundled-files: + - vendor + # 由项目自身的原生构建流程预编译。 objects: - native/build/startup.o @@ -333,6 +337,19 @@ ext-deps: `PHP_VERSION`、`PHP_VERSION_ID` 和 `PHP_OS_FAMILY`。命令行参数优先于 YAML 中的同名配置。原生链接依赖应写入 `link-libs`;`ext-deps` 会生成 `ZEND_MOD_REQUIRED`,缺少所需 PHP 扩展时由 Zend 拒绝加载模块。 +`bundled-files` 支持与 `sources` 相同的文件、目录及条件写法,仅在显式配置时启用。 +所列文件全部打包进二进制;未通过 `sources` 成功原生编译的 PHP 文件由 OPcache +生成字节码。运行时的 `require` 和 `require_once` 从内存交给 ZendVM 执行, +无需读取磁盘上的 PHP 文件。构建字节码的 PHP CLI、OPcache 与目标 PHP 运行时需匹配。 +运行二进制文件时无需 Composer 安装、磁盘 vendor 文件或 OPcache 扩展;Composer +自动加载文件也已嵌入,仍按需加载类。构建时有 OPcache 的情况下,匿名类在首次执行 +对应 `new class` 表达式时从同一字节码表加载。未配置 `bundled-files` 且缺少 +OPcache 时,匿名类退回内嵌 PHP 代码方式。 +仅当目录名为 `vendor` 且目录下存在 `autoload.php` 时,才会使用字节码缓存; +目录 mtime 及构建用的 PHP/OPcache 未变化时复用字节码。其他 `bundled-files` +文件(包括缺少 `autoload.php` 的同名目录)每次构建都重新生成。 +修改目录下已有文件不会更新目录 mtime;这种情况可用 `--force` 重新生成 +vendor 字节码。 通用的 `objects` 列表会把已有 `.o`/`.obj` 文件直接加入链接步骤。TypePHP 不会重新编译这些文件;原生编译器、目标架构、编译参数和增量构建均由项目负责。 使用目标通用参数的原生文件仍应放入 `sources`;仅当某个编译单元需要不同参数且 @@ -341,8 +358,9 @@ ext-deps: 项目级 `cxx-flags`、`c-flags`、`asm-flags` 和 `ld-flags` 分别应用于 C++、C、汇编和链接命令。 -构建目录保存生成的 C++、依赖对象和预编译头缓存。复用同一个构建目录可以显著加快 -增量构建;仅在确实需要重编 PHPX 公共对象时使用 `--force`。 +构建目录中的可读 C++ 和头文件与内部产物分开存放。对象文件、opcode 字节码、 +二进制归档、清单、链接响应文件和预编译头均放在 `build-dir/cache` 下。复用同一个 +构建目录可以显著加快增量构建;仅在确实需要重编 PHPX 公共对象时使用 `--force`。 全部项目配置项及命令行优先级详见[编译器命令行](docs/zh-cn/COMPILER_CLI.md)。 diff --git a/README.md b/README.md index b78f5bbd..2665eed9 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,10 @@ sources: - path: src/windows if: PHP_OS_FAMILY == "Windows" +# Embed files for ZendVM execution and virtual file reads. +bundled-files: + - vendor + # Precompiled by the project's external native build. objects: - native/build/startup.o @@ -366,6 +370,24 @@ directory; conditional entries support `PHP_VERSION`, `PHP_VERSION_ID`, and `PHP_OS_FAMILY`. CLI arguments override their YAML counterparts. Native linker dependencies belong in `link-libs`; `ext-deps` writes `ZEND_MOD_REQUIRED` entries so Zend can reject loading when a required PHP extension is missing. +`bundled-files` accepts files or directories with the same conditional syntax. +It is opt-in for embedded binary builds: all listed files are packed into the +binary, and PHP files not successfully compiled from `sources` are stored as +OPcache bytecode. `require` and `require_once` load those scripts through +ZendVM without reading their PHP files from disk. The PHP CLI and OPcache used +to build the blobs must match the target PHP runtime. The resulting binary +does not need Composer installation, vendor files, or an OPcache extension at +runtime: Composer's autoload files are embedded and still resolve classes on +demand. When OPcache is available at build time, anonymous classes use the +same opcode table when their `new class` expression is first executed. Without +`bundled-files`, builds lacking OPcache use embedded PHP code for anonymous +classes instead. +Only a `vendor` directory containing `autoload.php` uses the opcode cache. +Its blobs are reused while the directory mtime and build PHP/OPcache remain +unchanged. All other `bundled-files` files, including files in a `vendor` +directory without `autoload.php`, are regenerated on every build. Directory +mtime does not change when an existing nested file is edited; use `--force` +to regenerate vendor opcodes in that case. The generic `objects` list adds existing `.o`/`.obj` files directly to the link step. TypePHP never recompiles these files; the project owns their native compiler, architecture, flags, and incremental build. Keep native files that @@ -376,8 +398,10 @@ post-link packaging step after tpc emits its ELF. Project-wide `cxx-flags`, `c-flags`, `asm-flags`, and `ld-flags` are applied to C++, C, assembler, and link commands respectively. -The build directory contains generated C++, dependency objects, and the -precompiled-header cache. Reusing it makes incremental builds much faster; +The build directory keeps readable generated C++ and headers separate from +internal artifacts. Objects, opcode blobs, binary archives, manifests, linker +response files, and precompiled headers live under `build-dir/cache`. Reusing +the build directory makes incremental builds much faster; use `--force` only when the reusable PHPX objects must be rebuilt. See [Compiler CLI](docs/en/COMPILER_CLI.md) for all project keys and command-line diff --git a/project.yml b/project.yml index 501a2aeb..2972fa25 100644 --- a/project.yml +++ b/project.yml @@ -35,6 +35,9 @@ sources: - vendor/nikic/php-parser/lib/PhpParser/NodeVisitor.php - vendor/nikic/php-parser/lib/PhpParser/NodeVisitorAbstract.php +bundled-files: + - ./vendor + ignore: - ./src/Assert.php - ./src/polyfills.php diff --git a/src/Build/IncrementalCompilationTrait.php b/src/Build/IncrementalCompilationTrait.php index 038220eb..be5e2697 100644 --- a/src/Build/IncrementalCompilationTrait.php +++ b/src/Build/IncrementalCompilationTrait.php @@ -365,6 +365,7 @@ trait IncrementalCompilationTrait 'platform' => $this->targetPlatform, 'literalStrings' => !$this->noLiteralStrings, 'debug' => $this->debug, + 'anonymousOpcode' => $this->canEmbedAnonymousClassOpcode(), ])); // New self-hosted compiler builds embed their immutable source snapshot. diff --git a/src/Build/NativeCommandOptionsTrait.php b/src/Build/NativeCommandOptionsTrait.php index 6b86175b..cb01ce40 100644 --- a/src/Build/NativeCommandOptionsTrait.php +++ b/src/Build/NativeCommandOptionsTrait.php @@ -187,7 +187,8 @@ trait NativeCommandOptionsTrait 'sanitize' => $this->sanitize, 'lto' => $this->enableLto, 'target_platform' => $targetPlatform, - 'response_file' => $this->getBuildDir() . DIRECTORY_SEPARATOR + 'response_file' => $this->getBuildDir() . DIRECTORY_SEPARATOR . 'cache' + . DIRECTORY_SEPARATOR . 'link' . DIRECTORY_SEPARATOR . basename($this->getTargetFileName()) . '.rsp', ]; diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index 763ca1df..4818f328 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -8,6 +8,8 @@ namespace TypePhp\Build; +use Ajaxray\AnsiKit\AnsiTerminal; +use Ajaxray\AnsiKit\Components\Progressbar; use TypePhp\Backend\CompilerFactory; use TypePhp\Exception\SyntaxError; use TypePhp\Exception\Unsupported; @@ -20,6 +22,30 @@ use TypePhp\Platform\Windows; trait SourcePipelineTrait { use PreparedProjectCacheTrait; + + /** @var list PHP files selected by bundled-files. */ + private array $bundledPhpFiles = []; + + /** @var list All regular files selected by bundled-files. */ + private array $bundledFiles = []; + + /** @var list Selected PHP files not translated to native code. */ + private array $embeddedOpcodeFiles = []; + + /** @var list Anonymous classes generated from the current source file. */ + private array $currentAnonymousFiles = []; + + /** @var array Build-time PHP path to runtime anonymous-class key. */ + private array $anonymousOpcodeKeys = []; + + /** @var ?list Zend extension arguments for the build PHP CLI. */ + private ?array $opcodeBuildExtensionArgs = null; + + private bool $opcodeBuildChecked = false; + private string $opcodeBuildProbeError = ''; + private string $opcodeBuildPhpVersion = ''; + private string $opcodeBuildSignature = ''; + /** * Prepare PHP inputs for the Composer php-nano source-composition build. * @@ -100,6 +126,481 @@ trait SourcePipelineTrait $this->sourceDirs = array_merge($this->sourceDirs, $files); } + protected function embedAnonymousClassCode(string $name, string $code): string + { + $path = $this->getBuildDir() . '/cache/anonymous/source/anonymous-' . $this->targetName . '-' + . substr(hash('sha256', $this->file), 0, 12) . '-' . $name . '.php'; + $this->writeFile($path, "embeddedOpcodeFiles[] = $path; + $this->anonymousOpcodeKeys[$path] = $this->anonymousOpcodeKey($path); + $this->currentAnonymousFiles[] = $path; + return $this->anonymousOpcodeKeys[$path]; + } + + private function anonymousOpcodeKey(string $path): string + { + return '@typephp:anon:' . substr(hash('sha256', $path), 0, 32); + } + + private function anonymousManifestPath(string $source): string + { + return $this->getBuildDir() . '/cache/anonymous/manifests/' + . substr(hash('sha256', $source), 0, 20) . '.json'; + } + + private function moveLegacyBuildCache(string $legacy, string $current): void + { + if (!file_exists($legacy)) { + return; + } + $destination = $current; + if (file_exists($destination)) { + $base = $this->getBuildDir() . '/cache/legacy/' . basename($legacy); + $destination = $base; + for ($suffix = 1; file_exists($destination); ++$suffix) { + $destination = $base . '-' . $suffix; + } + } + $directory = dirname($destination); + if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new \RuntimeException("Cannot create build cache directory: {$directory}"); + } + if (!rename($legacy, $destination)) { + throw new \RuntimeException("Cannot move build cache into {$directory}: {$legacy}"); + } + } + + private function restoreAnonymousManifest(string $source): void + { + $manifest = $this->anonymousManifestPath($source); + if (!is_file($manifest)) { + return; + } + $paths = json_decode((string) file_get_contents($manifest), true); + if (!is_array($paths)) { + throw new \RuntimeException("Invalid anonymous class manifest: {$manifest}"); + } + foreach ($paths as $path) { + if (!is_string($path) || !is_file($path)) { + throw new \RuntimeException("Missing anonymous class source: {$path}"); + } + $this->embeddedOpcodeFiles[] = $path; + $this->anonymousOpcodeKeys[$path] = $this->anonymousOpcodeKey($path); + } + } + + /** Check whether the target PHP CLI can generate OPcache file-cache blobs. */ + private function probeOpcodeBuildExtensionArgs(): ?array + { + if ($this->opcodeBuildChecked) { + return $this->opcodeBuildExtensionArgs; + } + $this->opcodeBuildChecked = true; + + $php = $this->getPhpDir() . '/bin/php'; + if (!is_file($php) || !is_executable($php)) { + $this->opcodeBuildProbeError = "Build PHP CLI is not executable: {$php}"; + return null; + } + + foreach ([[], ['-d', 'zend_extension=opcache']] as $extensionArgs) { + $process = proc_open( + [ + $php, '-n', ...$extensionArgs, '-d', 'opcache.enable_cli=1', + '-r', 'if (!extension_loaded("Zend OPcache") || !function_exists("opcache_compile_file")) exit(1); ' + . '$extension = ini_get("extension_dir") . DIRECTORY_SEPARATOR . "opcache." . PHP_SHLIB_SUFFIX; ' + . 'echo json_encode(["php" => PHP_VERSION, "opcache" => phpversion("Zend OPcache"), ' + . '"binary" => [PHP_BINARY, hash_file("sha256", PHP_BINARY)], ' + . '"extension" => [$extension, is_file($extension) ? hash_file("sha256", $extension) : null], ' + . '"zts" => PHP_ZTS, "debug" => PHP_DEBUG, "int_size" => PHP_INT_SIZE]);', + ], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + if (!is_resource($process)) { + $this->opcodeBuildProbeError = "Cannot start the build PHP CLI: {$php}"; + return null; + } + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + if (proc_close($process) === 0) { + $signature = json_decode((string) $stdout, true); + if (!is_array($signature) || !is_string($signature['php'] ?? null)) { + throw new \RuntimeException("Invalid OPcache build PHP signature from {$php}"); + } + $this->opcodeBuildPhpVersion = $signature['php']; + $this->opcodeBuildSignature = (string) $stdout; + return $this->opcodeBuildExtensionArgs = $extensionArgs; + } + if ($stderr !== false && trim($stderr) !== '') { + $this->opcodeBuildProbeError = trim($stderr); + } + } + return null; + } + + private function getOpcodeBuildExtensionArgs(): array + { + $extensionArgs = $this->probeOpcodeBuildExtensionArgs(); + if ($extensionArgs !== null) { + return $extensionArgs; + } + $php = $this->getPhpDir() . '/bin/php'; + $detail = $this->opcodeBuildProbeError === '' ? '' : "\n" . $this->opcodeBuildProbeError; + $this->error("Embedded opcode generation requires Zend OPcache for the build PHP CLI: {$php}{$detail}"); + } + + protected function canEmbedAnonymousClassOpcode(): bool + { + return $this->isBuildModeBin() && !$this->isNanoMode() + && !$this->isIosTarget() && !$this->isAndroidTarget() && !$this->isWasiTarget() + && $this->probeOpcodeBuildExtensionArgs() !== null; + } + + private function startOpcodeProgress(string $label, int $total): ?Progressbar + { + if ($this->noProgress || $total === 0) { + return null; + } + $progress = new Progressbar(); + $progress->barStyle([AnsiTerminal::FG_GREEN]) + ->percentageStyle([AnsiTerminal::TEXT_BOLD]) + ->labelStyle([AnsiTerminal::FG_CYAN]); + $progress->renderInPlace(0, $total, $label); + return $progress; + } + + private function updateOpcodeProgress( + ?Progressbar $progress, + string $label, + int $completed, + int $total, + ?string $file = null, + ): void { + if ($progress !== null) { + $progress->renderInPlace($completed, $total, $label); + } elseif ($this->noProgress && ($file !== null || $completed % 100 === 0 || $completed === $total)) { + $percent = (int) ceil($completed / $total * 100); + $detail = $file === null ? '' : " {$file}"; + $this->output("[{$completed}/{$total}] {$percent}% {$label}{$detail}", 'white'); + } + } + + /** Only files under a vendor directory with autoload.php use the mtime cache. */ + private function vendorRootForOpcodeFile(string $file): ?string + { + for ($directory = dirname($file); $directory !== dirname($directory); $directory = dirname($directory)) { + if (basename($directory) === 'vendor' && is_file($directory . '/autoload.php')) { + return $directory; + } + } + return null; + } + + private function findOpcodeBlob(string $cacheDir, string $file): ?string + { + $matches = []; + foreach (glob($cacheDir . '/*', GLOB_ONLYDIR) ?: [] as $systemDirectory) { + $candidate = $systemDirectory . $file . '.bin'; + if (is_file($candidate) && filesize($candidate) > 0) { + $matches[] = $candidate; + } + } + if (count($matches) > 1) { + throw new \RuntimeException("Ambiguous OPcache blob for {$file}"); + } + if ($matches !== []) { + return $matches[0]; + } + // OPcache uses a different file-cache path layout on Windows. + if (DIRECTORY_SEPARATOR === '\\' && is_dir($cacheDir)) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($cacheDir, \FilesystemIterator::SKIP_DOTS), + ); + foreach ($iterator as $cached) { + if ($cached->isFile() && $cached->getSize() > 0 + && str_ends_with($cached->getPathname(), $file . '.bin')) { + if ($matches !== []) { + throw new \RuntimeException("Ambiguous OPcache blob for {$file}"); + } + $matches[] = $cached->getPathname(); + } + } + } + return $matches[0] ?? null; + } + + private function clearOpcodeCacheDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $entry) { + $path = $entry->getPathname(); + if ($entry->isDir() ? !rmdir($path) : !unlink($path)) { + throw new \RuntimeException("Cannot clear opcode build directory: {$path}"); + } + } + if (!rmdir($directory)) { + throw new \RuntimeException("Cannot clear opcode build directory: {$directory}"); + } + } + + /** Embed OPcache's file-cache bytes for scripts left to ZendVM. */ + private function genEmbeddedOpcodeTable(): array + { + $output = $this->getBuildDir() . '/embedded-opcodes-' . $this->targetName . '.cc'; + foreach (glob($this->getBuildDir() . '/opcache-*') ?: [] as $legacyCache) { + if (is_dir($legacyCache)) { + $this->moveLegacyBuildCache( + $legacyCache, + $this->getBuildDir() . '/cache/opcache/' . basename($legacyCache), + ); + } + } + $files = array_values(array_unique($this->embeddedOpcodeFiles)); + sort($files, SORT_STRING); + $blobs = []; + $phpVersion = PHP_VERSION; + + if ($files !== []) { + if ($this->isIosTarget() || $this->isAndroidTarget() || $this->isWasiTarget()) { + throw new \RuntimeException('Embedded opcodes require a native PHP build host matching the target'); + } + $this->output('Generating embedded opcodes for ' . count($files) . ' PHP files', 'lightBlue'); + $php = $this->getPhpDir() . '/bin/php'; + $extensionArgs = $this->getOpcodeBuildExtensionArgs(); + $phpVersion = $this->opcodeBuildPhpVersion; + $otherFiles = []; + $vendorRoots = []; + $vendorFiles = []; + $fileCacheDirs = []; + foreach ($files as $file) { + $vendorRoot = $this->vendorRootForOpcodeFile($file); + if ($vendorRoot === null) { + $otherFiles[] = $file; + continue; + } + $vendorRoots[$vendorRoot][] = $file; + $vendorFiles[$file] = true; + } + foreach ($vendorRoots as $vendorRoot => $filesInVendor) { + clearstatcache(true, $vendorRoot); + $mtime = filemtime($vendorRoot); + if ($mtime === false) { + throw new \RuntimeException("Cannot read vendor directory mtime: {$vendorRoot}"); + } + $key = hash('sha256', implode("\n", [ + 'vendor-directory-mtime-opcodes-v2', $vendorRoot, + (string) $mtime, $this->opcodeBuildSignature, + ])); + $cacheDir = $this->getBuildDir() . '/cache/opcache/vendor-' . substr($key, 0, 20); + if ($this->climate->arguments->defined('force')) { + $this->clearOpcodeCacheDirectory($cacheDir); + } + foreach ($filesInVendor as $file) { + $fileCacheDirs[$file] = $cacheDir; + } + } + if ($otherFiles !== []) { + // Recreate this staging directory on every build so OPcache + // cannot reuse bytecode for non-vendor or anonymous sources. + $cacheDir = $this->getBuildDir() . '/cache/opcache/nonvendor-' + . substr(hash('sha256', $this->targetName), 0, 20); + $this->clearOpcodeCacheDirectory($cacheDir); + foreach ($otherFiles as $file) { + $fileCacheDirs[$file] = $cacheDir; + } + } + $driver = $this->getBuildDir() . '/cache/opcache/compile-embedded-opcodes.php'; + $this->moveLegacyBuildCache( + $this->getBuildDir() . '/compile-embedded-opcodes.php', + $driver, + ); + $this->writeFile($driver, <<<'PHP' +findOpcodeBlob($cacheDir, $file)) !== null) { + $blobs[$file] = $blob; + ++$vendorHits; + } else { + $pending[] = $file; + } + } + if ($vendorRoots !== []) { + $vendorCount = array_sum(array_map('count', $vendorRoots)); + $this->output( + 'Vendor opcode cache: ' . $vendorHits . ' reused, ' + . ($vendorCount - $vendorHits) . ' to generate', + 'lightBlue', + ); + } + $progress = $this->startOpcodeProgress('Opcodes', count($pending)); + $completed = 0; + foreach ($pending as $file) { + $cacheDir = $fileCacheDirs[$file]; + if (!is_dir($cacheDir) && !mkdir($cacheDir, 0777, true) && !is_dir($cacheDir)) { + throw new \RuntimeException("Cannot create OPcache build directory: {$cacheDir}"); + } + $process = proc_open([...$command, '-d', 'opcache.file_cache=' . $cacheDir, $driver, $file], [ + 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], + ], $pipes); + if (!is_resource($process)) { + throw new \RuntimeException('Cannot start the PHP OPcache build process'); + } + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + if (proc_close($process) !== 0) { + throw new \RuntimeException("Cannot compile embedded opcode {$file}:\n{$stderr}"); + } + if (trim($stdout) !== $phpVersion) { + throw new \RuntimeException("Build PHP version changed while compiling {$file}"); + } + $blob = $this->findOpcodeBlob($cacheDir, $file); + if ($blob === null) { + throw new \RuntimeException("Missing OPcache blob for {$file}"); + } + $blobs[$file] = $blob; + $this->updateOpcodeProgress($progress, 'Opcodes', ++$completed, count($pending), $this->noProgress ? $file : null); + } + if ($progress !== null) { + echo PHP_EOL; + } + uksort($blobs, static fn(string $left, string $right): int => strcmp($left, $right)); + } + + $archive = $this->getBuildDir() . '/cache/embedded/embedded-files-' . $this->targetName . '.bin'; + $this->moveLegacyBuildCache( + $this->getBuildDir() . '/embedded-files-' . $this->targetName . '.bin', + $archive, + ); + $rawIndex = []; + $opcodeIndex = []; + $sources = [$output]; + if ($this->bundledFiles !== [] || $files !== []) { + $temporaryArchive = $archive . '.tmp'; + if (!is_dir(dirname($archive)) + && !mkdir(dirname($archive), 0777, true) && !is_dir(dirname($archive))) { + throw new \RuntimeException('Cannot create embedded archive directory: ' . dirname($archive)); + } + $stream = fopen($temporaryArchive, 'wb'); + if ($stream === false) { + throw new \RuntimeException("Cannot create embedded file archive: {$temporaryArchive}"); + } + foreach ($this->bundledFiles as $file) { + $offset = ftell($stream); + $input = fopen($file, 'rb'); + if ($input === false) { + throw new \RuntimeException("Cannot read embedded file: {$file}"); + } + $length = stream_copy_to_stream($input, $stream); + fclose($input); + if ($length === false) { + throw new \RuntimeException("Cannot copy embedded file: {$file}"); + } + $rawIndex[$file] = [$offset, $length]; + } + foreach ($blobs as $file => $blobFile) { + $offset = ftell($stream); + $input = fopen($blobFile, 'rb'); + if ($input === false) { + throw new \RuntimeException("Cannot read opcode blob: {$blobFile}"); + } + $length = stream_copy_to_stream($input, $stream); + fclose($input); + if ($length === false) { + throw new \RuntimeException("Cannot copy opcode blob: {$blobFile}"); + } + $opcodeIndex[$this->anonymousOpcodeKeys[$file] ?? $file] = [$offset, $length]; + } + fclose($stream); + if (!is_file($archive) || hash_file('sha256', $archive) !== hash_file('sha256', $temporaryArchive)) { + rename($temporaryArchive, $archive); + } else { + unlink($temporaryArchive); + } + $assembly = $this->getBuildDir() . '/embedded-files-' . $this->targetName . '.S'; + $quotedArchive = json_encode($archive, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + $archiveHash = hash_file('sha256', $archive); + $asmCode = "# archive-sha256: {$archiveHash}\n.section .rodata\n#ifdef __APPLE__\n.globl _typephp_embedded_archive_start\n.p2align 4\n_typephp_embedded_archive_start:\n#else\n.globl typephp_embedded_archive_start\n.p2align 4\ntypephp_embedded_archive_start:\n#endif\n.incbin {$quotedArchive}\n"; + $this->writeFile($assembly, $asmCode); + $this->generatedProjectSources[$assembly] = true; + $sources[] = $assembly; + $this->output( + 'Packed ' . count($rawIndex) . ' files and ' . count($opcodeIndex) . ' opcode blobs', + 'green', + ); + } + + $code = '#include ' . PHP_EOL; + if ($this->bundledFiles !== [] || $files !== []) { + $code .= 'extern "C" const uint8_t typephp_embedded_archive_start[];' . PHP_EOL; + } + $code .= 'static const typephp_opcode_entry typephp_opcodes[] = {' . PHP_EOL; + foreach ($opcodeIndex as $file => [$offset, $length]) { + $path = json_encode($file, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + $code .= " {{$path}, typephp_embedded_archive_start + {$offset}, {$length}}," . PHP_EOL; + } + $code .= ' {nullptr, nullptr, 0},' . PHP_EOL . '};' . PHP_EOL; + $code .= 'extern "C" const typephp_opcode_entry *typephp_project_opcode_table(size_t *count) {' . PHP_EOL; + $code .= ' *count = ' . count($opcodeIndex) . '; return typephp_opcodes;' . PHP_EOL . '}' . PHP_EOL; + $code .= 'static const typephp_embedded_file_entry typephp_embedded_files[] = {' . PHP_EOL; + foreach ($rawIndex as $file => [$offset, $length]) { + $path = json_encode($file, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + $code .= " {{$path}, typephp_embedded_archive_start + {$offset}, {$length}}," . PHP_EOL; + } + $code .= ' {nullptr, nullptr, 0},' . PHP_EOL . '};' . PHP_EOL; + $code .= 'extern "C" const typephp_embedded_file_entry *typephp_project_embedded_file_table(size_t *count) {' . PHP_EOL; + $code .= ' *count = ' . count($rawIndex) . '; return typephp_embedded_files;' . PHP_EOL . '}' . PHP_EOL; + $version = json_encode($phpVersion, JSON_THROW_ON_ERROR); + $code .= 'extern "C" const char *typephp_project_php_version(void) { return ' . $version . '; }' . PHP_EOL; + if ($rawIndex === [] && $opcodeIndex === []) { + $code .= 'extern "C" void typephp_opcode_table_install(void) {}' . PHP_EOL; + $code .= 'extern "C" void typephp_opcode_table_uninstall(void) {}' . PHP_EOL; + } + $this->writeFile($output, $code); + $this->generatedProjectSources[$output] = true; + foreach (glob($this->getBuildDir() . '/anonymous-*.json') ?: [] as $legacyManifest) { + unlink($legacyManifest); + } + foreach (glob($this->getBuildDir() . '/anonymous-' . $this->targetName . '-*.php') ?: [] as $legacySource) { + unlink($legacySource); + } + return $sources; + } + public function getFiles(string $path): array { $this->applyPhpVersionCommandLineArgument(); @@ -134,6 +635,12 @@ trait SourcePipelineTrait // Apply command-line arguments after all configuration is loaded (so they // take the highest precedence) $this->applyCommandLineArguments(); + if ($this->bundledFiles !== [] && !$this->isBuildModeBin()) { + $this->error('`bundled-files` requires `mode: bin`'); + } + if ($this->bundledFiles !== []) { + $this->getOpcodeBuildExtensionArgs(); + } if ($this->climate->arguments->defined('force')) { $this->clearIncrementalBuildCache(); } @@ -227,6 +734,7 @@ trait SourcePipelineTrait $files = $this->filterIgnoredFiles($files); $preparedKey = $this->preparedProjectKey($files); if ($this->restorePreparedProject($preparedKey)) { + $this->embeddedOpcodeFiles = array_values(array_diff($this->bundledPhpFiles, $files)); $files = $this->getSortedFiles($files); $this->initializeIncrementalCompilation($files); return $files; @@ -260,6 +768,7 @@ trait SourcePipelineTrait $this->storePreparedProject($preparedKey); } $files = $this->getSortedFiles($files); + $this->embeddedOpcodeFiles = array_values(array_diff($this->bundledPhpFiles, $files)); $this->initializeIncrementalCompilation($files); return $files; } @@ -392,7 +901,27 @@ trait SourcePipelineTrait try { if (FileScanner::isPhpFile($file)) { $path = realpath($file) ?: $file; - if (!$this->shouldRegeneratePhpFile($path)) { + $anonymousManifest = $this->anonymousManifestPath($path); + $legacyAnonymousManifest = $this->getBuildDir() . '/anonymous-' + . substr(hash('sha256', $path), 0, 20) . '.json'; + if (is_file($legacyAnonymousManifest) + && file_get_contents($legacyAnonymousManifest) === '[]') { + unlink($legacyAnonymousManifest); + } + // Older builds wrote [] for every PHP file. Those files + // carry no cache data and can be removed on a cache hit. + if (is_file($anonymousManifest) + && file_get_contents($anonymousManifest) === '[]') { + unlink($anonymousManifest); + } + $needsAnonymousRefresh = is_file($legacyAnonymousManifest) + || ($this->canEmbedAnonymousClassOpcode() + && !is_file($anonymousManifest) + && preg_match('/new\s+class\b/', (string) file_get_contents($path)) === 1); + if (!$this->shouldRegeneratePhpFile($path) && !$needsAnonymousRefresh) { + if ($this->canEmbedAnonymousClassOpcode()) { + $this->restoreAnonymousManifest($path); + } $validSourceCount++; if ($this->incrementalTranslationUnitWasEmitted($path)) { $cppFile = $this->getCppFile($path); @@ -409,12 +938,24 @@ trait SourcePipelineTrait continue; } $statisticsBefore = $this->compilationStatistics->all(); + $this->currentAnonymousFiles = []; // A dirty dependency means code generation must run; // it does not mean the generated bytes changed. Keep // an existing translation unit's timestamp when the // output is identical, matching CMake/Ninja's restat // model and preventing needless native recompilation. $cppFile = $this->convertFile($path); + if ($this->currentAnonymousFiles !== []) { + $this->writeFile($anonymousManifest, json_encode( + array_values(array_unique($this->currentAnonymousFiles)), + JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + )); + } elseif (is_file($anonymousManifest)) { + unlink($anonymousManifest); + } + if (is_file($legacyAnonymousManifest)) { + unlink($legacyAnonymousManifest); + } $this->recordIncrementalConversion( $path, $cppFile !== null, @@ -438,6 +979,10 @@ trait SourcePipelineTrait } catch (Unsupported $e) { echo ' unsupported syntax: ' . $e->getMessage() . "\n"; echo ' skip: ' . $file . "\n"; + if (in_array($file, $this->bundledPhpFiles, true) + && !in_array($file, $this->embeddedOpcodeFiles, true)) { + $this->embeddedOpcodeFiles[] = $file; + } unset($files[$k]); } } @@ -470,6 +1015,9 @@ trait SourcePipelineTrait // Nano keeps the ordinary statically registered Zend class/module // metadata, then adds a direct native process entry beside it. $sourceFiles[] = $this->genExtension(); + if ($this->isBuildModeEmbed() && !$this->isNanoMode()) { + array_push($sourceFiles, ...$this->genEmbeddedOpcodeTable()); + } if ($this->isNanoMode()) { $sourceFiles[] = $this->genNanoEntrypoint(); } diff --git a/src/Build/TranslationUnitSplitTrait.php b/src/Build/TranslationUnitSplitTrait.php index 68718d5d..aac36467 100644 --- a/src/Build/TranslationUnitSplitTrait.php +++ b/src/Build/TranslationUnitSplitTrait.php @@ -12,7 +12,15 @@ trait TranslationUnitSplitTrait protected function getSplitTranslationUnits(string $source): array { $primary = $this->getCppFile($source); - $manifest = $primary . '.parts.json'; + $manifest = $this->splitTranslationUnitManifestPath($primary); + $legacy = $primary . '.parts.json'; + if (is_file($legacy)) { + $contents = file_get_contents($legacy); + if (!is_file($manifest) && is_string($contents) && $contents !== '[]') { + $this->writeFile($manifest, $contents); + } + unlink($legacy); + } $parts = is_file($manifest) ? json_decode((string) file_get_contents($manifest), true) : []; if (!is_array($parts)) { return []; @@ -23,6 +31,11 @@ trait TranslationUnitSplitTrait && preg_match('/^' . preg_quote($primary, '/') . '\\.part-[0-9]+\\.cc$/D', $part) === 1)); } + private function splitTranslationUnitManifestPath(string $primary): string + { + return $this->getBuildDir() . '/cache/parts/' . hash('sha256', $primary) . '.json'; + } + private function splitLargeTranslationUnit(string $code, string $primary, bool $force): string { $oldParts = $this->getSplitTranslationUnits($this->file); @@ -64,8 +77,11 @@ trait TranslationUnitSplitTrait $parts[] = $part; } } - if ($this->splitTranslationUnitsEnabled || $oldParts !== []) { - $this->writeFile($primary . '.parts.json', json_encode($parts, JSON_THROW_ON_ERROR)); + $manifest = $this->splitTranslationUnitManifestPath($primary); + if ($parts !== []) { + $this->writeFile($manifest, json_encode($parts, JSON_THROW_ON_ERROR)); + } elseif (is_file($manifest)) { + unlink($manifest); } foreach (array_diff($oldParts, $parts) as $obsolete) { foreach ([$obsolete, $this->getObjectFile($obsolete), diff --git a/src/CompilerBase.php b/src/CompilerBase.php index ddc3f21f..b4694cb4 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -4279,15 +4279,26 @@ class CompilerBase implements PropertyAccessContext } } $this->flattenEmbeddedClassTraits($classDef); - // Anonymous classes are defined by eval in the root namespace, + // Embedded classes are defined in the root namespace, // so symbols imported inside them must be converted to // fully-qualified names. $this->resolveAnonClassNames($classDef); - $this->context->beforeStmtLines[] = 'static THREAD_LOCAL bool ' . $className . '_defined = false;'; $classCode = $this->genEmbeddedCode($classDef); - $this->addConstData($className . '_code', $classCode); - $this->context->beforeStmtLines[] = 'if (!' . $className . '_defined) {' - . $className . '_defined = true; php::eval((const char *)' . $className . '_code);}'; + if ($this->isBuildModeBin() && !$this->isNanoMode() + && !$this->isIosTarget() && !$this->isAndroidTarget() && !$this->isWasiTarget() + && method_exists($this, 'embedAnonymousClassCode') + && method_exists($this, 'canEmbedAnonymousClassOpcode') + && $this->canEmbedAnonymousClassOpcode()) { + $opcodePath = $this->embedAnonymousClassCode($className, $classCode); + $this->localHeaders[] = 'typephp_opcode_table.h'; + $this->context->beforeStmtLines[] = 'typephp_opcode_table_require("' + . $this->escapeString($opcodePath) . '");'; + } else { + $this->context->beforeStmtLines[] = 'static THREAD_LOCAL bool ' . $className . '_defined = false;'; + $this->addConstData($className . '_code', $classCode); + $this->context->beforeStmtLines[] = 'if (!' . $className . '_defined) {' + . $className . '_defined = true; php::eval((const char *)' . $className . '_code);}'; + } $className = '\\' . $className; $cePtr = $this->getClassEntryPtr($className); $ctorClassName = $className; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index a1c47585..0a18b8ce 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -342,15 +342,15 @@ class Preprocessor extends CompilerBase $ext = $this->getPlatform()->getObjectExtension(); // Composer packages are immutable build inputs. Keep their objects in - // the project build directory instead of writing beside vendor sources. + // the project cache instead of writing beside vendor sources. if (isset($this->nanoRuntimeSources[$cppFile])) { $separator = $this->getPlatform()->getPathSeparator(); - $objectDir = $this->buildDir . $separator . 'nano-objects'; - if (!is_dir($objectDir)) { - mkdir($objectDir, 0777, true); - } - return $objectDir . $separator . $info['filename'] . '-' - . substr(sha1($cppFile), 0, 12) . $ext; + $name = $info['filename'] . '-' . substr(sha1($cppFile), 0, 12) . $ext; + return $this->cacheObjectFile( + $this->buildDir . $separator . 'cache' . $separator . 'objects' + . $separator . 'nano' . $separator . $name, + $this->buildDir . $separator . 'nano-objects' . $separator . $name, + ); } // Keep the same path separator as cppFile @@ -362,11 +362,12 @@ class Preprocessor extends CompilerBase // other PHPX misc sources are target-independent and share their // cached object files within the build directory. $cacheScope = $this->isProjectRuntimeEntryFile($cppFile) ? $this->targetName : 'shared'; - $objectDir = $this->buildDir . $separator . 'phpx-misc' . $separator . $cacheScope; - if (!is_dir($objectDir)) { - mkdir($objectDir, 0777, true); - } - return $objectDir . $separator . $info['filename'] . $ext; + $name = $info['filename'] . $ext; + return $this->cacheObjectFile( + $this->buildDir . $separator . 'cache' . $separator . 'objects' + . $separator . 'phpx-misc' . $separator . $cacheScope . $separator . $name, + $this->buildDir . $separator . 'phpx-misc' . $separator . $cacheScope . $separator . $name, + ); } $filename = $info['filename']; @@ -376,7 +377,46 @@ class Preprocessor extends CompilerBase $filename .= '.' . $info['extension']; } - return $info['dirname'] . $this->getPlatform()->getPathSeparator() . $filename . $ext; + $separator = $this->getPlatform()->getPathSeparator(); + $legacy = $info['dirname'] . $separator . $filename . $ext; + $normalizedBuildDir = rtrim(str_replace('\\', '/', $this->buildDir), '/') . '/'; + if (str_starts_with($normalizedFile, $normalizedBuildDir)) { + $relative = substr($normalizedFile, strlen($normalizedBuildDir)); + $relativeDir = dirname($relative); + $objectDir = $this->buildDir . $separator . 'cache' . $separator . 'objects' + . $separator . 'generated'; + if ($relativeDir !== '.') { + $objectDir .= $separator . str_replace('/', $separator, $relativeDir); + } + } else { + $objectDir = $this->buildDir . $separator . 'cache' . $separator . 'objects' + . $separator . 'external' . $separator . substr(sha1($info['dirname']), 0, 16); + // A legacy object beside an external source may belong to a + // different build directory. Leave that file alone. + $legacy = null; + } + return $this->cacheObjectFile($objectDir . $separator . $filename . $ext, $legacy); + } + + private function cacheObjectFile(string $object, ?string $legacy): string + { + $directory = dirname($object); + if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new \RuntimeException('Cannot create object cache directory: ' . $directory); + } + if ($legacy !== null) { + foreach (['', '.typephp-cache'] as $suffix) { + if (!is_file($legacy . $suffix)) { + continue; + } + if (is_file($object . $suffix)) { + unlink($legacy . $suffix); + } else { + rename($legacy . $suffix, $object . $suffix); + } + } + } + return $object; } private function isGeneratedCppFile(string $file): bool diff --git a/src/Translator.php b/src/Translator.php index b1684f47..a2edb44f 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -762,6 +762,9 @@ class Translator extends Preprocessor try { $cppCode = $this->doConvert($phpCode); $cppFile = $this->getCppFile($file); + if ($this->isIncludeOnlyTranslationUnit($cppCode)) { + $cppCode = ''; + } $this->recordEmittedTypes($cppCode); $cppCode = $this->splitLargeTranslationUnit($cppCode, $cppFile, $forceWrite); if ($cppCode === '') { @@ -788,6 +791,17 @@ class Translator extends Preprocessor } } + private function isIncludeOnlyTranslationUnit(string $code): bool + { + foreach (preg_split('/\R/', $code) as $line) { + $line = trim($line); + if ($line !== '' && !str_starts_with($line, '#include ')) { + return false; + } + } + return true; + } + /** * Remove artifacts left by an earlier build when a PHP source no longer * emits a C++ translation unit. Trait-only files are the common case. @@ -2196,6 +2210,21 @@ CODE; $sourceFiles[] = $runtimeSource; } $sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_main.cc'; + if ($this->bundledFiles !== [] || $this->embeddedOpcodeFiles !== []) { + $sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_opcode_table.cc'; + $versionHeader = $this->getPhpDir() . '/include/php/main/php_version.h'; + $versionText = is_file($versionHeader) ? file_get_contents($versionHeader) : ''; + if (!preg_match('/#define PHP_VERSION_ID\s+(\d+)/', $versionText, $versionMatch)) { + throw new \RuntimeException("Cannot determine the target PHP version from {$versionHeader}"); + } + $targetPhpVersion = (int) $versionMatch[1]; + $decoder = match (true) { + $targetPhpVersion >= 80400 && $targetPhpVersion < 80500 => 'opcode_unserialize_84.c', + $targetPhpVersion >= 80500 && $targetPhpVersion < 80600 => 'opcode_unserialize_85.c', + default => throw new \RuntimeException("Unsupported opcode decoder PHP version: {$targetPhpVersion}"), + }; + $sourceFiles[] = $this->getPhpxDir() . '/thirdparty/opcache/' . $decoder; + } } if (!$this->isNanoMode() @@ -2525,7 +2554,8 @@ CODE; private function getLinkCacheMetadataFile(string $targetFile): string { - return $this->getBuildDir() . DIRECTORY_SEPARATOR + return $this->getBuildDir() . DIRECTORY_SEPARATOR . 'cache' + . DIRECTORY_SEPARATOR . 'link' . DIRECTORY_SEPARATOR . basename($targetFile) . '.typephp-link-cache'; } @@ -2533,6 +2563,10 @@ CODE; private function writeLinkCache(array $objectFiles, string $targetFile): void { $metadata = $this->getLinkCacheMetadataFile($targetFile); + if (!is_dir(dirname($metadata)) && !mkdir(dirname($metadata), 0777, true) + && !is_dir(dirname($metadata))) { + throw new \RuntimeException('Cannot create link cache directory: ' . dirname($metadata)); + } if (file_put_contents($metadata, $this->getLinkCacheKey($objectFiles, $targetFile) . PHP_EOL) === false) { throw new \RuntimeException('Cannot write link cache metadata: ' . $metadata); } @@ -3832,6 +3866,52 @@ CODE; $list = $this->getFilesFromDir($projectDir); } + // Raw files are bundled; PHP files not emitted by `sources` are also + // compiled into OPcache blobs for ZendVM execution at runtime. + if (array_key_exists('opcode-sources', $cfg)) { + $this->error('`opcode-sources` has been renamed to `bundled-files`'); + } + if (array_key_exists('bundled-files', $cfg)) { + if (!is_array($cfg['bundled-files'])) { + $this->error('`bundled-files` must be an array'); + } + foreach ($cfg['bundled-files'] as $entry) { + [$src, $condition] = $this->parseProjectYamlSourceEntry($entry); + if ($condition !== null && !$this->evaluateProjectYamlCondition($condition)) { + continue; + } + $resolved = $this->getAbsolutePath($src, $projectDir); + if (!$resolved) { + $this->error('Bundled file or directory does not exist: `' . $src . '`'); + } + if (is_file($resolved)) { + $this->bundledFiles[] = $resolved; + continue; + } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($resolved, \FilesystemIterator::SKIP_DOTS), + ); + foreach ($iterator as $file) { + if ($file->isFile()) { + $this->bundledFiles[] = $file->getPathname(); + } + } + } + $this->bundledFiles = array_values(array_unique($this->bundledFiles)); + sort($this->bundledFiles, SORT_STRING); + $this->bundledPhpFiles = array_values(array_filter( + $this->bundledFiles, + static fn(string $file): bool => FileScanner::isPhpFile($file), + )); + if ($this->bundledFiles !== []) { + $this->output( + 'bundled-files: found ' . count($this->bundledFiles) + . ' files (' . count($this->bundledPhpFiles) . ' PHP)', + 'lightBlue', + ); + } + } + if (array_key_exists('optimize', $cfg)) { $this->optimizeLevel = (int) $cfg['optimize']; } diff --git a/src/compiler.php b/src/compiler.php index 4ca43554..e6475b14 100644 --- a/src/compiler.php +++ b/src/compiler.php @@ -32,7 +32,7 @@ function main(int $argc, array $argv): void // in bin/bootstrap.php. The AOT compiler starts here directly and therefore // must load the dependencies packaged alongside tpc itself. if (!defined('TYPEPHP_PHP_SCRIPT_ENTRY')) { - require_once resolveComposerAutoloader(); + require_once dirname(__DIR__) . '/vendor/autoload.php'; } $completionStatus = CompletionCommand::execute($argv); @@ -136,43 +136,6 @@ function main(int $argc, array $argv): void } } -/** - * Locate the Composer autoloader that ships with the compiler. - * - * A source checkout and a Unix-like package keep it below TYPEPHP_ROOT_PATH, - * while a packaged SDK extracts it below PHP_HOME. - */ -function resolveComposerAutoloader(): string -{ - $candidates = []; - $roots = [TYPEPHP_ROOT_PATH, getenv('PHP_HOME') ?: null]; - foreach ($roots as $root) { - if (!is_string($root) || $root === '') { - continue; - } - $autoloadFile = rtrim($root, '/\\') . '/vendor/autoload.php'; - if (!in_array($autoloadFile, $candidates, true)) { - $candidates[] = $autoloadFile; - } - } - - foreach ($candidates as $candidate) { - if (is_file($candidate)) { - return $candidate; - } - } - - fwrite(STDERR, "Unable to find the Composer autoloader (vendor/autoload.php).\n"); - fwrite(STDERR, "Searched in:\n"); - foreach ($candidates as $candidate) { - fwrite(STDERR, " - {$candidate}\n"); - } - fwrite(STDERR, "\nInstall the TypePHP dependencies with Composer first:\n"); - fwrite(STDERR, " cd " . TYPEPHP_ROOT_PATH . " && composer install\n"); - fwrite(STDERR, "Or point PHP_HOME at a TypePHP installation that already contains vendor/autoload.php.\n"); - exit(1); -} - function shouldCompileNativeSourceProject(array $argv): bool { foreach (array_slice($argv, 1) as $argument) {