feat(compiler): add CLI entry point and improve PHP builder functionality

- Add --entry option to specify PHP entry file for CLI SAPI execution
- Make --php-builder option optional with default empty configuration {}
- Update documentation to reflect new --entry and optional --php-builder options
- Add CompilerToolchain class to manage C/C++ compiler and archiver tools
- Implement response file support for long object lists in archiving operations
- Add progress tracking and detailed build stage reporting for PHP compilation
- Support MSVC, GCC and Clang compiler toolchains with proper companion tools
- Add test cases for new entry point and toolchain functionality
- Adjust build pipeline to exclude entry file from source compilation
- Improve build logging and error handling during compilation processes
master
韩天峰 2 weeks ago
parent 6e6e596661
commit b893d865d1
  1. 3
      README-CN.md
  2. 3
      README.md
  3. 2
      completions/tpc.bash
  4. 3
      docs/en/COMPILER_CLI.md
  5. 3
      docs/zh-cn/COMPILER_CLI.md
  6. 60
      phpunit/src/Build/CompilerToolchainTest.php
  7. 8
      phpunit/src/Build/PhpBuilderConfigurationTest.php
  8. 119
      phpunit/src/Build/SapiPhpBuilderTest.php
  9. 45
      phpunit/src/CompilerBaseApiTest.php
  10. 161
      src/Build/CompilerToolchain.php
  11. 2
      src/Build/PhpBuilderConfiguration.php
  12. 361
      src/Build/SapiPhpBuilder.php
  13. 54
      src/Build/SourcePipelineTrait.php
  14. 6
      src/Metadata/Constants.php
  15. 73
      src/Translator.php

@ -688,7 +688,8 @@ bin/tpc.php --wasm=browser app.php
| `-o`, `--output <file>` | 输出文件名 | | `-o`, `--output <file>` | 输出文件名 |
| `-m`, `--mode <bin\|lib\|ext>` | 构建模式(默认 `bin`) | | `-m`, `--mode <bin\|lib\|ext>` | 构建模式(默认 `bin`) |
| `--sapi <embed\|cli\|fpm>` | 二进制使用的 SAPI(默认 `embed`,支持逗号分隔多个值) | | `--sapi <embed\|cli\|fpm>` | 二进制使用的 SAPI(默认 `embed`,支持逗号分隔多个值) |
| `--php-builder <配置>` | 从 php-src 构建私有 PHP 运行时 | | `--entry <file>` | CLI SAPI 启动时执行的 PHP 入口文件 |
| `--php-builder[=<配置>]` | 从 php-src 构建私有 PHP 运行时;省略配置时默认为 `{}` |
| `-r`, `--run` | 构建成功后运行 | | `-r`, `--run` | 构建成功后运行 |
| `-j`, `--job <num>` | 并行编译任务数(默认 `4`) | | `-j`, `--job <num>` | 并行编译任务数(默认 `4`) |
| `-f`, `--force` | 不使用缓存,重新编译可复用 PHPX 对象 | | `-f`, `--force` | 不使用缓存,重新编译可复用 PHPX 对象 |

@ -755,7 +755,8 @@ Key options:
| `-o`, `--output <file>` | Output file name | | `-o`, `--output <file>` | Output file name |
| `-m`, `--mode <bin\|lib\|ext>` | Build mode (default `bin`) | | `-m`, `--mode <bin\|lib\|ext>` | Build mode (default `bin`) |
| `--sapi <embed\|cli\|fpm>` | Binary SAPI target (default `embed`; comma-separated lists accepted) | | `--sapi <embed\|cli\|fpm>` | Binary SAPI target (default `embed`; comma-separated lists accepted) |
| `--php-builder <config>` | Build a private PHP runtime from php-src | | `--entry <file>` | PHP entry file executed when the CLI SAPI starts |
| `--php-builder[=<config>]` | Build a private PHP runtime from php-src; omitted config defaults to `{}` |
| `-r`, `--run` | Run after a successful build | | `-r`, `--run` | Run after a successful build |
| `-j`, `--job <num>` | Parallel compile jobs (default `4`) | | `-j`, `--job <num>` | Parallel compile jobs (default `4`) |
| `-f`, `--force` | Rebuild reusable PHPX objects instead of using the cache | | `-f`, `--force` | Rebuild reusable PHPX objects instead of using the cache |

@ -144,7 +144,7 @@ _typephp_tpc()
return return
;; ;;
-* ) -* )
COMPREPLY=( $(compgen -W '--nano -O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version --proxy -f --force -m --mode --php-builder --sapi -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") ) COMPREPLY=( $(compgen -W '--nano -O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version --proxy -f --force -m --mode --php-builder --sapi --entry -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") )
return return
;; ;;
esac esac

@ -68,7 +68,8 @@ bin/tpc.php app.php --dry --build-dir /tmp/typephp-build
| `-o`, `--output <file>` | Output file name. | | `-o`, `--output <file>` | Output file name. |
| `-m`, `--mode <bin|lib|ext>` | Build mode, default `bin`. | | `-m`, `--mode <bin|lib|ext>` | Build mode, default `bin`. |
| `--sapi <embed|cli|fpm>` | SAPI used by a `bin` target; accepts a comma-separated list. Default: `embed`. | | `--sapi <embed|cli|fpm>` | SAPI used by a `bin` target; accepts a comma-separated list. Default: `embed`. |
| `--php-builder <config>` | Build PHP from php-src, for example `--php-builder='extensions: [swoole, mongodb]; zts: on'`. | | `--entry <file>` | PHP entry file executed by the CLI SAPI; the CLI value overrides YAML `entry`. |
| `--php-builder[=<config>]` | Build PHP from php-src; omitted config defaults to `{}`, for example `--php-builder='extensions: [swoole, mongodb]; zts: on'`. |
| `-r`, `--run` | Run after a successful build. | | `-r`, `--run` | Run after a successful build. |
| `-j`, `--job <num>` | Number of parallel compilation jobs, default `4`. | | `-j`, `--job <num>` | Number of parallel compilation jobs, default `4`. |
| `-f`, `--force` | Ignore the phpx misc object cache and force recompilation. | | `-f`, `--force` | Ignore the phpx misc object cache and force recompilation. |

@ -68,7 +68,8 @@ bin/tpc.php app.php --dry --build-dir /tmp/typephp-build
| `-o`, `--output <file>` | 输出文件名。 | | `-o`, `--output <file>` | 输出文件名。 |
| `-m`, `--mode <bin|lib|ext>` | 构建模式,默认 `bin`。 | | `-m`, `--mode <bin|lib|ext>` | 构建模式,默认 `bin`。 |
| `--sapi <embed|cli|fpm>` | `bin` 目标使用的 SAPI,支持逗号分隔的多个值;默认 `embed`。 | | `--sapi <embed|cli|fpm>` | `bin` 目标使用的 SAPI,支持逗号分隔的多个值;默认 `embed`。 |
| `--php-builder <配置>` | 从 php-src 构建 PHP,例如 `--php-builder='extensions: [swoole, mongodb]; zts: on'`。 | | `--entry <file>` | CLI SAPI 启动时执行的 PHP 入口文件;命令行配置覆盖 YAML 的 `entry`。 |
| `--php-builder[=<配置>]` | 从 php-src 构建 PHP;省略配置时默认为 `{}`,例如 `--php-builder='extensions: [swoole, mongodb]; zts: on'`。 |
| `-r`, `--run` | 构建成功后运行。 | | `-r`, `--run` | 构建成功后运行。 |
| `-j`, `--job <num>` | 并行编译任务数,默认 `4`。 | | `-j`, `--job <num>` | 并行编译任务数,默认 `4`。 |
| `-f`, `--force` | 忽略 phpx misc 对象缓存,强制重新编译。 | | `-f`, `--force` | 忽略 phpx misc 对象缓存,强制重新编译。 |

@ -0,0 +1,60 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Tests\Build;
use PHPUnit\Framework\TestCase;
use TypePhp\Backend\Clang;
use TypePhp\Backend\Gcc;
use TypePhp\Backend\Msvc;
use TypePhp\Build\CompilerToolchain;
use TypePhp\Platform\Linux;
use TypePhp\Platform\Macos;
use TypePhp\Platform\Windows;
/**
* @internal
* @coversNothing
*/
final class CompilerToolchainTest extends TestCase
{
public function testDerivesCrossGccCompanionTools(): void
{
$toolchain = CompilerToolchain::fromBackend(new Gcc(
new Linux(),
'/opt/cross/bin/aarch64-linux-gnu-g++',
));
self::assertSame('/opt/cross/bin/aarch64-linux-gnu-gcc', $toolchain->cCompiler);
self::assertSame('/opt/cross/bin/aarch64-linux-gnu-g++', $toolchain->cxxCompiler);
self::assertSame('/opt/cross/bin/aarch64-linux-gnu-gcc-ar', $toolchain->archiver);
self::assertSame(CompilerToolchain::ARCHIVER_UNIX, $toolchain->archiverStyle);
}
public function testDerivesVersionedClangCompanionTools(): void
{
$toolchain = CompilerToolchain::fromBackend(new Clang(
new Macos(),
'/opt/llvm/bin/clang++-18',
));
self::assertSame('/opt/llvm/bin/clang-18', $toolchain->cCompiler);
self::assertSame('/opt/llvm/bin/clang++-18', $toolchain->cxxCompiler);
self::assertSame('/opt/llvm/bin/llvm-ar-18', $toolchain->archiver);
}
public function testSelectsMsvcLibraryManager(): void
{
$toolchain = CompilerToolchain::fromBackend(new Msvc(new Windows(), 'cl'));
self::assertSame('cl', $toolchain->cCompiler);
self::assertSame('cl', $toolchain->cxxCompiler);
self::assertContains(strtolower(basename($toolchain->archiver)), ['lib', 'lib.exe']);
self::assertSame(CompilerToolchain::ARCHIVER_MSVC, $toolchain->archiverStyle);
}
}

@ -38,6 +38,14 @@ final class PhpBuilderConfigurationTest extends TestCase
self::assertSame(['curl', 'mbstring'], $configuration->extensions); self::assertSame(['curl', 'mbstring'], $configuration->extensions);
} }
public function testEmptyCommandLineConfigurationUsesMappingDefaults(): void
{
$configuration = PhpBuilderConfiguration::fromCommandLine('');
self::assertFalse($configuration->zts);
self::assertSame([], $configuration->extensions);
}
public function testRejectsLegacyListOfMappingsYamlShape(): void public function testRejectsLegacyListOfMappingsYamlShape(): void
{ {
$this->expectException(\InvalidArgumentException::class); $this->expectException(\InvalidArgumentException::class);

@ -0,0 +1,119 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Tests\Build;
use PHPUnit\Framework\TestCase;
use TypePhp\Build\CompilerToolchain;
use TypePhp\Build\SapiPhpBuilder;
/**
* @internal
* @coversNothing
*/
final class SapiPhpBuilderTest extends TestCase
{
public function testExtractsUniqueMakeObjectTargetsForProgress(): void
{
$builder = new SapiPhpBuilder(__DIR__, static function (string $message): void {});
$method = new \ReflectionMethod($builder, 'extractObjectTargets');
self::assertSame([
'ext/json/json.lo',
'Zend/zend.lo',
'main/main.lo',
], $method->invoke($builder, implode("\n", [
'cc -c ext/json/json.c -o ext/json/json.lo -MMD',
"cc -c Zend/zend.c -o 'Zend/zend.lo' -MMD",
'cc -c main/main.c -o "main/main.lo" -MMD',
'cc -c ext/json/json.c -o ext/json/json.lo -MMD',
'cc objects.o -o sapi/cli/php',
])));
}
public function testAbbreviatesLongArchiverCommands(): void
{
$builder = new SapiPhpBuilder(__DIR__, static function (string $message): void {});
$method = new \ReflectionMethod($builder, 'displayCommand');
self::assertSame(
"'ar' 'rcs' '/tmp/libphp.a' '@<6 object files>'",
$method->invoke($builder, [
'ar',
'rcs',
'/tmp/libphp.a',
'one.o',
'two.o',
'three.o',
'four.o',
'five.o',
'six.o',
]),
);
}
public function testBuildsArchiveCommandForSelectedToolchain(): void
{
$gcc = new SapiPhpBuilder(
__DIR__,
static function (string $message): void {},
toolchain: new CompilerToolchain(
'/opt/gcc/bin/gcc',
'/opt/gcc/bin/g++',
'/opt/gcc/bin/gcc-ar',
CompilerToolchain::ARCHIVER_UNIX,
),
);
$method = new \ReflectionMethod($gcc, 'archiveCommand');
self::assertSame(
['/opt/gcc/bin/gcc-ar', 'rcs', '/tmp/libphp.a', '@/tmp/objects.rsp'],
$method->invoke($gcc, '/tmp/libphp.a', '@/tmp/objects.rsp'),
);
$msvc = new SapiPhpBuilder(
__DIR__,
static function (string $message): void {},
toolchain: new CompilerToolchain('cl', 'cl', 'lib.exe', CompilerToolchain::ARCHIVER_MSVC),
);
self::assertSame(
['lib.exe', '/NOLOGO', '/OUT:C:\\tmp\\php.lib', '@C:\\tmp\\objects.rsp'],
$method->invoke($msvc, 'C:\\tmp\\php.lib', '@C:\\tmp\\objects.rsp'),
);
}
public function testReservesOneProgressStepUntilMakeActuallyFinishes(): void
{
$builder = new SapiPhpBuilder(__DIR__, static function (string $message): void {});
$method = new \ReflectionMethod($builder, 'liveProgressValue');
self::assertSame(630, $method->invoke($builder, 634, 634));
self::assertSame(512, $method->invoke($builder, 512, 634));
self::assertSame(0, $method->invoke($builder, 1, 1));
}
public function testDetectsPostCompilationBuildStages(): void
{
$builder = new SapiPhpBuilder(__DIR__, static function (string $message): void {});
$method = new \ReflectionMethod($builder, 'detectBuildStage');
self::assertSame(
'Linking OPcache',
$method->invoke($builder, 'libtool --mode=link gcc -shared -o ext/opcache/opcache.la objects.o'),
);
self::assertSame(
'Linking PHP CLI',
$method->invoke($builder, 'libtool --mode=link gcc objects.o -o sapi/cli/php'),
);
self::assertSame(
'Linking PHP FPM',
$method->invoke($builder, 'libtool --mode=link gcc objects.o -o sapi/fpm/php-fpm'),
);
self::assertSame('Generating phar.php', $method->invoke($builder, 'Generating phar.php'));
self::assertSame('Generating phar.phar', $method->invoke($builder, 'Generating phar.phar'));
}
}

@ -1013,6 +1013,35 @@ YAML);
$this->invokeMethod('validateLoadedProjectConfiguration'); $this->invokeMethod('validateLoadedProjectConfiguration');
} }
public function testCommandLineEntryConfiguresPhpBuilderCliSapi(): void
{
global $argv;
$source = $this->testDir . '/foo.php';
$entry = $this->testDir . '/index.php';
file_put_contents($source, "<?php\nfunction compiled_helper(): void {}\n");
file_put_contents($entry, "<?php\ncompiled_helper();\n");
$argv = [
'compiler.php',
$source,
'--sapi=cli',
'--php-builder',
'--entry=' . $entry,
];
$compiler = CompilerTest::create($this->testDir);
$files = $compiler->getFiles($source);
$reflection = new \ReflectionClass($compiler);
$entryProperty = $reflection->getProperty('sapiEntryFile');
$embeddedFilesProperty = (new \ReflectionClass(\TypePhp\Translator::class))
->getProperty('embeddedFiles');
$this->assertSame([$source], $files);
$this->assertSame(['cli'], $compiler->getSapiTargets());
$this->assertTrue($compiler->isPhpBuilderBuild());
$this->assertSame($entry, $entryProperty->getValue($compiler));
$this->assertContains($entry, $embeddedFilesProperty->getValue($compiler));
}
public function testPhpBuilderCliRequiresEntry(): void public function testPhpBuilderCliRequiresEntry(): void
{ {
$projectFile = $this->createProjectFile(<<<'YAML' $projectFile = $this->createProjectFile(<<<'YAML'
@ -1165,6 +1194,22 @@ YAML, 'myproject.yml', 'cli-output');
$this->assertSame('out_file', $targetProp->getValue($compiler)); $this->assertSame('out_file', $targetProp->getValue($compiler));
} }
public function testBarePhpBuilderOptionKeepsFollowingSourcePositional(): void
{
global $argv;
$source = $this->testDir . '/hello.php';
file_put_contents($source, "<?php\nfunction main(): void {}\n");
$argv = ['compiler.php', '--php-builder', $source];
$compiler = CompilerTest::create($this->testDir);
$method = (new \ReflectionClass($compiler))->getMethod('applyCommandLineArguments');
$method->setAccessible(true);
$method->invoke($compiler);
$this->assertSame(['compiler.php', '--php-builder={}', $source], $argv);
$this->assertTrue($compiler->isPhpBuilderBuild());
}
public function testApplyCommandLineArgumentsDoesNotClearYamlRepeatableOptionsWhenCliAbsent(): void public function testApplyCommandLineArgumentsDoesNotClearYamlRepeatableOptionsWhenCliAbsent(): void
{ {
$projectFile = $this->createProjectFile(<<<'YAML' $projectFile = $this->createProjectFile(<<<'YAML'

@ -0,0 +1,161 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Build;
use TypePhp\Backend\Clang;
use TypePhp\Backend\CompilerBackend;
use TypePhp\Backend\Gcc;
use TypePhp\Backend\Msvc;
use TypePhp\Installer\PhpBuildConfiguration;
final readonly class CompilerToolchain
{
public const string ARCHIVER_UNIX = 'unix';
public const string ARCHIVER_MSVC = 'msvc';
public function __construct(
public string $cCompiler,
public string $cxxCompiler,
public string $archiver,
public string $archiverStyle,
) {
}
public static function fromBackend(CompilerBackend $backend): self
{
$cxx = $backend->getCompilerCommand();
[$words, $compilerIndex] = self::compilerWords($cxx);
$compiler = $words[$compilerIndex];
if ($backend instanceof Gcc) {
$cCompiler = self::replaceCompilerWord($words, $compilerIndex, self::gccCCompiler($compiler));
$archiver = self::resolveCompanion($compiler, self::gccArchiverNames($compiler));
return new self($cCompiler, $cxx, $archiver, self::ARCHIVER_UNIX);
}
if ($backend instanceof Clang) {
$cCompiler = self::replaceCompilerWord($words, $compilerIndex, self::clangCCompiler($compiler));
$archiver = self::resolveCompanion($compiler, self::clangArchiverNames($compiler));
return new self($cCompiler, $cxx, $archiver, self::ARCHIVER_UNIX);
}
if ($backend instanceof Msvc) {
$archiver = self::resolveCompanion($compiler, ['lib.exe', 'lib']);
return new self($cxx, $cxx, $archiver, self::ARCHIVER_MSVC);
}
throw new \RuntimeException('Unsupported PHP builder compiler toolchain: ' . $backend->getName());
}
/** @return array{list<string>, int} */
private static function compilerWords(string $command): array
{
$words = PhpBuildConfiguration::parseShellWords($command);
if ($words === []) {
throw new \RuntimeException('The compiler command is empty');
}
for ($index = count($words) - 1; $index >= 0; --$index) {
$name = strtolower(preg_replace('/\.exe$/i', '', basename(str_replace('\\', '/', $words[$index]))) ?? '');
if (preg_match('/(?:^|-)clang(?:\+\+|-cl)?(?:-\d+)?$|(?:^|-)(?:g\+\+|gcc|c\+\+|cl)(?:-\d+)?$/', $name) === 1) {
return [$words, $index];
}
}
return [$words, 0];
}
/** @param list<string> $words */
private static function replaceCompilerWord(array $words, int $index, string $compiler): string
{
$words[$index] = $compiler;
if (count($words) === 1) {
return $compiler;
}
return implode(' ', array_map('escapeshellarg', $words));
}
private static function gccCCompiler(string $compiler): string
{
return self::replaceBasename($compiler, static function (string $name): string {
$name = preg_replace('/g\+\+/', 'gcc', $name, 1, $count) ?? $name;
if ($count === 0) {
$name = preg_replace('/c\+\+/', 'cc', $name, 1) ?? $name;
}
return $name;
});
}
private static function clangCCompiler(string $compiler): string
{
return self::replaceBasename(
$compiler,
static fn (string $name): string => preg_replace('/clang\+\+/', 'clang', $name, 1) ?? $name,
);
}
/** @return list<string> */
private static function gccArchiverNames(string $compiler): array
{
$name = basename(str_replace('\\', '/', $compiler));
$gccAr = preg_replace('/g\+\+|gcc|c\+\+/', 'gcc-ar', $name, 1, $count) ?? $name;
if ($count === 0) {
$gccAr = 'gcc-ar';
}
$plainAr = preg_replace('/g\+\+|gcc|c\+\+/', 'ar', $name, 1, $plainCount) ?? $name;
if ($plainCount === 0) {
$plainAr = 'ar';
}
return array_values(array_unique([$gccAr, 'gcc-ar', $plainAr, 'ar']));
}
/** @return list<string> */
private static function clangArchiverNames(string $compiler): array
{
$name = strtolower(preg_replace('/\.exe$/i', '', basename(str_replace('\\', '/', $compiler))) ?? '');
$suffix = preg_match('/clang(?:\+\+)?-(\d+)$/', $name, $match) === 1 ? '-' . $match[1] : '';
$names = ['llvm-ar' . $suffix, 'llvm-ar'];
if (PHP_OS_FAMILY === 'Darwin') {
// Xcode ships the LLVM archiver as /usr/bin/ar on some releases.
$names[] = 'ar';
}
return array_values(array_unique($names));
}
/** @param list<string> $names */
private static function resolveCompanion(string $compiler, array $names): string
{
$normalized = str_replace('\\', '/', $compiler);
$hasDirectory = str_contains($normalized, '/');
if ($hasDirectory) {
$directory = dirname($normalized);
foreach ($names as $name) {
$resolved = ExecutableLocator::resolve($directory . DIRECTORY_SEPARATOR . $name);
if ($resolved !== null) {
return $resolved;
}
}
// An explicitly selected compiler directory defines the toolchain
// boundary. Do not silently take an unrelated archiver from PATH.
return $directory . DIRECTORY_SEPARATOR . $names[0];
}
foreach ($names as $name) {
$resolved = ExecutableLocator::resolve($name);
if ($resolved !== null) {
return $resolved;
}
}
return $names[0];
}
private static function replaceBasename(string $path, callable $replace): string
{
$normalized = str_replace('\\', '/', $path);
$name = $replace(basename($normalized));
if (!str_contains($normalized, '/')) {
return $name;
}
return dirname($normalized) . DIRECTORY_SEPARATOR . $name;
}
}

@ -33,7 +33,7 @@ final readonly class PhpBuilderConfiguration
{ {
$value = trim($value); $value = trim($value);
if ($value === '') { if ($value === '') {
throw new \InvalidArgumentException('Option --php-builder requires a non-empty configuration'); return self::fromYaml([]);
} }
try { try {

@ -13,14 +13,22 @@ use TypePhp\Installer\PhpBuildConfiguration;
final class SapiPhpBuilder final class SapiPhpBuilder
{ {
private readonly \Closure $output; private readonly \Closure $output;
private readonly ?\Closure $progress;
private ?CompilerToolchain $activeToolchain = null;
/** @param callable(string):void $output */ /**
* @param callable(string):void $output
* @param null|callable(int, int, string, bool):void $progress
*/
public function __construct( public function __construct(
private readonly string $phpxSourceDirectory, private readonly string $phpxSourceDirectory,
callable $output, callable $output,
private readonly ?string $proxy = null, private readonly ?string $proxy = null,
?callable $progress = null,
private readonly ?CompilerToolchain $toolchain = null,
) { ) {
$this->output = \Closure::fromCallable($output); $this->output = \Closure::fromCallable($output);
$this->progress = $progress === null ? null : \Closure::fromCallable($progress);
} }
/** /**
@ -40,6 +48,12 @@ final class SapiPhpBuilder
if ($targets === [] || array_diff($targets, ['embed', 'cli', 'fpm']) !== []) { if ($targets === [] || array_diff($targets, ['embed', 'cli', 'fpm']) !== []) {
throw new \InvalidArgumentException('PHP builder SAPI targets must contain embed, cli, or fpm'); throw new \InvalidArgumentException('PHP builder SAPI targets must contain embed, cli, or fpm');
} }
$this->activeToolchain = $this->toolchain ?? new CompilerToolchain(
getenv('CC') ?: 'cc',
getenv('CXX') ?: 'c++',
getenv('AR') ?: 'ar',
CompilerToolchain::ARCHIVER_UNIX,
);
$officialSource = (new OfficialPhpSource( $officialSource = (new OfficialPhpSource(
OfficialPhpSource::defaultCacheDirectory(), OfficialPhpSource::defaultCacheDirectory(),
$this->output, $this->output,
@ -66,8 +80,9 @@ final class SapiPhpBuilder
$externalExtensions, $externalExtensions,
PHP_OS_FAMILY, PHP_OS_FAMILY,
php_uname('m'), php_uname('m'),
getenv('CC') ?: '', $this->activeToolchain->cCompiler,
getenv('CXX') ?: '', $this->activeToolchain->cxxCompiler,
$this->activeToolchain->archiver,
filemtime($source . '/configure'), filemtime($source . '/configure'),
]; ];
$compatibility = hash('sha256', json_encode($identity, JSON_THROW_ON_ERROR)); $compatibility = hash('sha256', json_encode($identity, JSON_THROW_ON_ERROR));
@ -124,6 +139,7 @@ final class SapiPhpBuilder
if ($needsInstall) { if ($needsInstall) {
($this->output)('Building private PHP runtime (cached across application builds)'); ($this->output)('Building private PHP runtime (cached across application builds)');
$this->run(['make', '-j' . max(1, $jobs)], $build); $this->run(['make', '-j' . max(1, $jobs)], $build);
($this->output)('Installing private PHP runtime');
$this->run(['make', 'install'], $build); $this->run(['make', 'install'], $build);
} }
@ -325,7 +341,22 @@ final class SapiPhpBuilder
); );
($this->output)('Caching PHP ' . strtoupper($target) . ' runtime: ' . $archives[$target]); ($this->output)('Caching PHP ' . strtoupper($target) . ' runtime: ' . $archives[$target]);
$temporary = $archives[$target] . '.part-' . bin2hex(random_bytes(6)); $temporary = $archives[$target] . '.part-' . bin2hex(random_bytes(6));
$this->run(['ar', 'rcs', $temporary, ...$objects], $build); $responseFile = $archives[$target] . '.objects.rsp';
AtomicFile::write(
$responseFile,
implode(PHP_EOL, array_map($this->quoteResponseFileArgument(...), $objects)) . PHP_EOL,
);
try {
$this->run($this->archiveCommand($temporary, '@' . $responseFile), $build);
} catch (\RuntimeException) {
// GNU ar and llvm-ar accept @response files. Keep a fallback
// for older platform archivers while still abbreviating the
// displayed command so hundreds of object paths are not
// written to the user's terminal.
@unlink($temporary);
($this->output)('Archiver response files are unavailable; retrying with direct arguments');
$this->run($this->archiveCommand($temporary, ...$objects), $build);
}
if (!rename($temporary, $archives[$target])) { if (!rename($temporary, $archives[$target])) {
@unlink($temporary); @unlink($temporary);
throw new \RuntimeException('Unable to store PHP SAPI archive: ' . $archives[$target]); throw new \RuntimeException('Unable to store PHP SAPI archive: ' . $archives[$target]);
@ -388,17 +419,133 @@ final class SapiPhpBuilder
/** @param list<string> $command */ /** @param list<string> $command */
private function run(array $command, string $directory): void private function run(array $command, string $directory): void
{ {
($this->output)('$ ' . implode(' ', array_map('escapeshellarg', $command))); ($this->output)('$ ' . $this->displayCommand($command));
$logPath = rtrim($directory, '/\\') . '/.typephp-build.log'; $logPath = rtrim($directory, '/\\') . '/.typephp-build.log';
$log = fopen($logPath, 'ab'); $log = fopen($logPath, 'ab');
if ($log === false) { if ($log === false) {
throw new \RuntimeException("Unable to open SAPI build log: {$logPath}"); throw new \RuntimeException("Unable to open SAPI build log: {$logPath}");
} }
fwrite($log, PHP_EOL . '$ ' . implode(' ', array_map('escapeshellarg', $command)) . PHP_EOL); fwrite($log, PHP_EOL . '$ ' . implode(' ', array_map('escapeshellarg', $command)) . PHP_EOL);
$process = proc_open($command, [STDIN, $log, $log], $pipes, $directory);
$status = is_resource($process) ? proc_close($process) : -1; $pendingObjects = $this->pendingMakeObjects($command, $directory);
$pendingObjectSet = array_fill_keys($pendingObjects, true);
$isCmakeBuild = basename($command[0]) === 'cmake' && in_array('--build', $command, true);
$progressTotal = $pendingObjects !== [] ? count($pendingObjects) : ($isCmakeBuild ? 100 : 0);
$progressLabel = $pendingObjects !== [] ? 'Building PHP' : 'Building PHPX';
$progressStage = $progressLabel;
$completed = 0;
$completedObjects = [];
if ($progressTotal !== 0 && $this->progress !== null) {
($this->progress)(0, $progressTotal, $progressLabel, false);
}
$process = proc_open($command, [
STDIN,
['pipe', 'w'],
['pipe', 'w'],
], $pipes, $directory, $this->processEnvironment());
if (!is_resource($process)) {
fclose($log);
throw new \RuntimeException('Unable to start command: ' . implode(' ', $command));
}
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$lineBuffers = [1 => '', 2 => ''];
$startedAt = microtime(true);
$lastHeartbeatAt = $startedAt;
$exitStatus = -1;
while (true) {
$read = [];
foreach ([1, 2] as $index) {
if (!feof($pipes[$index])) {
$read[] = $pipes[$index];
}
}
if ($read !== []) {
$write = null;
$except = null;
@stream_select($read, $write, $except, 1, 0);
foreach ($read as $stream) {
$index = $stream === $pipes[1] ? 1 : 2;
while (($chunk = fread($stream, 8192)) !== false && $chunk !== '') {
fwrite($log, $chunk);
$lineBuffers[$index] .= $chunk;
$this->consumeProgressLines(
$lineBuffers[$index],
$pendingObjectSet,
$completedObjects,
$completed,
$progressTotal,
$progressLabel,
$progressStage,
$isCmakeBuild,
);
}
}
} else {
usleep(100_000);
}
$processStatus = proc_get_status($process);
$now = microtime(true);
if ($processStatus['running'] && $now - $lastHeartbeatAt >= 5.0) {
if ($progressTotal !== 0 && $this->progress !== null) {
($this->progress)(
$this->liveProgressValue($completed, $progressTotal),
$progressTotal,
sprintf('%s (%ds)', $progressStage, (int) ($now - $startedAt)),
false,
);
} else {
($this->output)(sprintf(
'Build command still running (%ds); output: %s',
(int) ($now - $startedAt),
$logPath,
));
}
$lastHeartbeatAt = $now;
}
if (!$processStatus['running']) {
$exitStatus = $processStatus['exitcode'];
break;
}
}
foreach ([1, 2] as $index) {
$chunk = stream_get_contents($pipes[$index]);
if (is_string($chunk) && $chunk !== '') {
fwrite($log, $chunk);
$lineBuffers[$index] .= $chunk;
}
$this->consumeProgressLines(
$lineBuffers[$index],
$pendingObjectSet,
$completedObjects,
$completed,
$progressTotal,
$progressLabel,
$progressStage,
$isCmakeBuild,
true,
);
fclose($pipes[$index]);
}
$closeStatus = proc_close($process);
if ($exitStatus < 0) {
$exitStatus = $closeStatus;
}
if ($progressTotal !== 0 && $this->progress !== null) {
if ($exitStatus === 0) {
$completed = $progressTotal;
}
($this->progress)(
$completed,
$progressTotal,
$exitStatus === 0 ? $progressLabel . ' complete' : $progressLabel . ' failed',
true,
);
}
fclose($log); fclose($log);
if ($status !== 0) { if ($exitStatus !== 0) {
$contents = (string) @file_get_contents($logPath); $contents = (string) @file_get_contents($logPath);
$lines = preg_split('/\R/', trim($contents)) ?: []; $lines = preg_split('/\R/', trim($contents)) ?: [];
$tail = implode(PHP_EOL, array_slice($lines, -40)); $tail = implode(PHP_EOL, array_slice($lines, -40));
@ -406,6 +553,204 @@ final class SapiPhpBuilder
} }
} }
private function quoteResponseFileArgument(string $argument): string
{
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $argument) . '"';
}
/** @return list<string> */
private function archiveCommand(string $archive, string ...$objects): array
{
$toolchain = $this->activeToolchain ?? $this->toolchain;
if ($toolchain === null) {
throw new \LogicException('PHP builder toolchain was not initialized');
}
if ($toolchain->archiverStyle === CompilerToolchain::ARCHIVER_MSVC) {
return [$toolchain->archiver, '/NOLOGO', '/OUT:' . $archive, ...$objects];
}
return [$toolchain->archiver, 'rcs', $archive, ...$objects];
}
/** @param list<string> $command */
private function displayCommand(array $command): string
{
$program = strtolower(preg_replace('/\.exe$/i', '', basename(str_replace('\\', '/', $command[0]))) ?? '');
if ((str_ends_with($program, 'ar') || $program === 'lib') && count($command) > 8) {
$objectCount = count($command) - 3;
return implode(' ', array_map('escapeshellarg', array_slice($command, 0, 3)))
. ' ' . escapeshellarg("@<{$objectCount} object files>");
}
return implode(' ', array_map('escapeshellarg', $command));
}
/** @param list<string> $command @return list<string> */
private function pendingMakeObjects(array $command, string $directory): array
{
if (basename($command[0]) !== 'make'
|| array_filter(array_slice($command, 1), static fn (string $arg): bool => !str_starts_with($arg, '-')) !== []
) {
return [];
}
$process = proc_open(
[$command[0], '-n', '-j1'],
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
$directory,
$this->processEnvironment(),
);
if (!is_resource($process)) {
return [];
}
$output = stream_get_contents($pipes[1]);
$error = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
if (proc_close($process) !== 0) {
return [];
}
return $this->extractObjectTargets((string) $output . PHP_EOL . (string) $error);
}
/** @return list<string> */
private function extractObjectTargets(string $output): array
{
preg_match_all(
'/(?:^|\s)-o\s+(?:\'([^\']+\.lo)\'|"([^"]+\.lo)"|([^\s\'";]+\.lo))(?=\s|$)/m',
$output,
$matches,
PREG_SET_ORDER,
);
$targets = [];
foreach ($matches as $match) {
$target = $match[1] !== '' ? $match[1] : ($match[2] !== '' ? $match[2] : $match[3]);
$targets[$target] = true;
}
return array_keys($targets);
}
/**
* @param array<string, true> $pendingObjects
* @param array<string, true> $completedObjects
*/
private function consumeProgressLines(
string &$buffer,
array $pendingObjects,
array &$completedObjects,
int &$completed,
int $total,
string $label,
string &$stage,
bool $isCmakeBuild,
bool $flush = false,
): void {
$lines = preg_split('/\R/', $buffer);
if ($lines === false) {
return;
}
$buffer = $flush ? '' : (array_pop($lines) ?? '');
foreach ($lines as $line) {
$nextStage = $this->detectBuildStage($line);
if ($nextStage !== null && $nextStage !== $stage) {
$stage = $nextStage;
if ($this->progress !== null && $total !== 0) {
($this->progress)(
$this->liveProgressValue($completed, $total),
$total,
$stage,
false,
);
}
}
if ($pendingObjects !== []) {
foreach ($this->extractObjectTargets($line) as $target) {
if (!isset($pendingObjects[$target]) || isset($completedObjects[$target])) {
continue;
}
$completedObjects[$target] = true;
++$completed;
if ($this->progress !== null) {
if ($completed >= $total) {
$stage = 'Finishing PHP compilation';
($this->progress)(
$this->liveProgressValue($completed, $total),
$total,
$stage,
false,
);
} else {
($this->progress)(
$this->liveProgressValue($completed, $total),
$total,
$target,
false,
);
}
}
}
} elseif ($isCmakeBuild && preg_match('/\[\s*(\d{1,3})%\]/', $line, $match) === 1) {
$next = min($total, (int) $match[1]);
if ($next > $completed) {
$completed = $next;
if ($this->progress !== null) {
($this->progress)($this->liveProgressValue($completed, $total), $total, $label, false);
}
}
}
}
}
private function liveProgressValue(int $completed, int $total): int
{
// Progressbar rounds to the nearest integer percentage, so total - 1
// can still render as 100% for a large PHP build (633/634 = 99.84%).
// Reserve enough of the tail to keep every live update below 99.5%;
// the successful process exit is the only event allowed to show 100%.
$maximumLive = max(0, (int) ceil($total * 0.995) - 1);
return min($completed, $maximumLive);
}
private function detectBuildStage(string $line): ?string
{
if (str_contains($line, '--mode=link')) {
if (preg_match('/(?:^|\s)-o\s+[^\s]*opcache(?:\.la|\.so)(?:\s|$)/', $line) === 1) {
return 'Linking OPcache';
}
if (preg_match('#(?:^|\s)-o\s+(?:\'|")?sapi/cli/php(?:\'|"|\s|$)#', $line) === 1) {
return 'Linking PHP CLI';
}
if (preg_match('#(?:^|\s)-o\s+(?:\'|")?sapi/fpm/php-fpm(?:\'|"|\s|$)#', $line) === 1) {
return 'Linking PHP FPM';
}
if (preg_match('#(?:^|\s)-o\s+(?:\'|")?(?:libs/)?libphp(?:\.la|\.a)(?:\'|"|\s|$)#', $line) === 1) {
return 'Linking PHP Embed';
}
}
if (str_starts_with($line, 'Generating phar.php')) {
return 'Generating phar.php';
}
if (str_starts_with($line, 'Generating phar.phar')) {
return 'Generating phar.phar';
}
return null;
}
/** @return array<string, string>|null */
private function processEnvironment(): ?array
{
$toolchain = $this->activeToolchain ?? $this->toolchain;
if ($toolchain === null) {
return null;
}
$environment = getenv();
if (!is_array($environment)) {
$environment = [];
}
$environment['CC'] = $toolchain->cCompiler;
$environment['CXX'] = $toolchain->cxxCompiler;
$environment['AR'] = $toolchain->archiver;
return $environment;
}
private function mkdir(string $directory): void private function mkdir(string $directory): void
{ {
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {

@ -479,6 +479,9 @@ trait SourcePipelineTrait
// only after a YAML project has loaded all included configuration. // only after a YAML project has loaded all included configuration.
$this->applyCommandLineArguments(); $this->applyCommandLineArguments();
$this->validateLoadedProjectConfiguration(); $this->validateLoadedProjectConfiguration();
if ($this->sapiEntryFile !== null) {
$files = array_values(array_diff($files, [$this->sapiEntryFile]));
}
$files = $this->excludeGeneratedLibraryStub($files); $files = $this->excludeGeneratedLibraryStub($files);
return $this->filterIgnoredFiles($files); return $this->filterIgnoredFiles($files);
} }
@ -704,10 +707,61 @@ trait SourcePipelineTrait
return; return;
} }
try { try {
$phpBuildProgress = null;
$lastLineProgress = -1;
$lastLineProgressLabel = '';
$runtime = (new SapiPhpBuilder( $runtime = (new SapiPhpBuilder(
$this->getPhpxDir(), $this->getPhpxDir(),
fn (string $message) => $this->output($message, 'lightBlue'), fn (string $message) => $this->output($message, 'lightBlue'),
$this->downloadProxy, $this->downloadProxy,
function (
int $completed,
int $total,
string $detail,
bool $finished,
) use (&$phpBuildProgress, &$lastLineProgress, &$lastLineProgressLabel): void {
if ($total === 0) {
return;
}
$isStage = preg_match(
'/^(?:Building|Finishing|Linking|Generating|Installing|PHP runtime)/',
$detail,
) === 1;
$label = $isStage ? $detail : 'Building PHP';
if ($this->noProgress) {
if ($completed !== $lastLineProgress || $label !== $lastLineProgressLabel || $finished) {
$percent = min(100, (int) floor($completed / $total * 100));
$suffix = $detail === '' || $isStage
? ''
: ' ' . $detail;
$this->output("[{$completed}/{$total}] {$percent}% {$label}{$suffix}", 'white');
$lastLineProgress = $completed;
$lastLineProgressLabel = $label;
}
return;
}
if ($phpBuildProgress === null) {
$phpBuildProgress = new Progressbar();
$phpBuildProgress->width(30)
->barStyle([AnsiTerminal::FG_GREEN])
->percentageStyle([AnsiTerminal::TEXT_BOLD])
->labelStyle([AnsiTerminal::FG_CYAN])
;
}
// A dynamic "(31s)" suffix shifts the bar on every digit
// boundary, and shorter stage names leave bytes from the
// previous frame behind. Keep the TTY layout fixed; the
// elapsed heartbeat remains visible with --no-progress.
$ttyLabel = preg_replace('/ \(\d+s\)$/', '', $label) ?? $label;
$phpBuildProgress->renderInPlace($completed, $total, str_pad($ttyLabel, 26));
if ($finished) {
echo PHP_EOL;
$phpBuildProgress = null;
$lastLineProgress = -1;
$lastLineProgressLabel = '';
}
},
CompilerToolchain::fromBackend($this->getCompilerBackend()),
))->prepare( ))->prepare(
$this->phpVersion, $this->phpVersion,
$this->sapiTargets, $this->sapiTargets,

@ -200,12 +200,18 @@ class Constants
'longPrefix' => 'php-builder', 'longPrefix' => 'php-builder',
'description' => 'Build PHP from source, e.g. "extensions: [curl]; zts: on"', 'description' => 'Build PHP from source, e.g. "extensions: [curl]; zts: on"',
'required' => false, 'required' => false,
'defaultValue' => '{}',
], ],
'sapi' => [ 'sapi' => [
'longPrefix' => 'sapi', 'longPrefix' => 'sapi',
'description' => 'PHP SAPI target: embed, cli, fpm, or a comma-separated list', 'description' => 'PHP SAPI target: embed, cli, fpm, or a comma-separated list',
'required' => false, 'required' => false,
], ],
'entry' => [
'longPrefix' => 'entry',
'description' => 'PHP entry file executed by the CLI SAPI',
'required' => false,
],
'run' => [ 'run' => [
'prefix' => 'r', 'prefix' => 'r',
'longPrefix' => 'run', 'longPrefix' => 'run',

@ -363,11 +363,12 @@ class Translator extends Preprocessor
{ {
$path = null; $path = null;
for ($i = 1; $i < count($argv); $i++) { for ($i = 1; $i < count($argv); $i++) {
if ($argv[$i] === '--proxy') { if ($argv[$i] === '--proxy' || $argv[$i] === '--entry') {
++$i; ++$i;
continue; continue;
} }
if (str_starts_with($argv[$i], '--proxy=')) { if (str_starts_with($argv[$i], '--proxy=')
|| str_starts_with($argv[$i], '--entry=')) {
continue; continue;
} }
if ($argv[$i] !== '' && $argv[$i][0] !== '-') { if ($argv[$i] !== '' && $argv[$i][0] !== '-') {
@ -439,7 +440,8 @@ class Translator extends Preprocessor
['-f, --force', 'Clear incremental caches and force a full rebuild'], ['-f, --force', 'Clear incremental caches and force a full rebuild'],
['-m, --mode <mode>', 'Build mode: bin, lib, or ext (default: bin)'], ['-m, --mode <mode>', 'Build mode: bin, lib, or ext (default: bin)'],
['--sapi <target>', 'PHP SAPI target: embed (default), CLI, and/or FPM'], ['--sapi <target>', 'PHP SAPI target: embed (default), CLI, and/or FPM'],
['--php-builder <config>', 'Build a private PHP runtime from source'], ['--entry <file>', 'PHP entry file executed by the CLI SAPI'],
['--php-builder[=<config>]', 'Build a private PHP runtime from source (default: {})'],
['-r, --run', 'Run the compiled binary after a successful build'], ['-r, --run', 'Run the compiled binary after a successful build'],
['-j, --job <num>', 'Number of parallel compilation jobs (default: 4)'], ['-j, --job <num>', 'Number of parallel compilation jobs (default: 4)'],
['--cxx-std <ver>', 'C++ standard version (default: c++17)'], ['--cxx-std <ver>', 'C++ standard version (default: c++17)'],
@ -514,6 +516,12 @@ class Translator extends Preprocessor
if ($this->climate->arguments->defined('sapi')) { if ($this->climate->arguments->defined('sapi')) {
$this->configureSapiTargets((string) $this->climate->arguments->get('sapi')); $this->configureSapiTargets((string) $this->climate->arguments->get('sapi'));
} }
if ($this->climate->arguments->defined('entry')) {
$this->configureSapiEntry(
(string) $this->climate->arguments->get('entry'),
getcwd() ?: $this->rootPath,
);
}
if ($this->climate->arguments->defined('php-builder')) { if ($this->climate->arguments->defined('php-builder')) {
try { try {
$this->configurePhpBuilder(PhpBuilderConfiguration::fromCommandLine( $this->configurePhpBuilder(PhpBuilderConfiguration::fromCommandLine(
@ -1020,7 +1028,12 @@ class Translator extends Preprocessor
for ($i = 1; $i < count($argv); $i++) { for ($i = 1; $i < count($argv); $i++) {
$arg = $argv[$i]; $arg = $argv[$i];
if (preg_match('/^-([a-zA-Z])(.+)$/', $arg, $matches)) { if ($arg === '--php-builder') {
// The configuration is optional, but CLImate otherwise treats
// the following positional source as this option's value.
// Explicit configurations use --php-builder="...".
$processed[] = '--php-builder={}';
} elseif (preg_match('/^-([a-zA-Z])(.+)$/', $arg, $matches)) {
$option = $matches[1]; $option = $matches[1];
$value = $matches[2]; $value = $matches[2];
$processed[] = "-{$option}"; $processed[] = "-{$option}";
@ -2917,7 +2930,7 @@ CODE;
$objectFiles, $objectFiles,
[$sapiObject, $internalFunctionsObject], [$sapiObject, $internalFunctionsObject],
)); ));
$this->linkNativeTarget($embedObjects, $outputs['embed']); $this->linkNativeTarget($embedObjects, $outputs['embed'], false);
} }
$runnableTargets = array_values(array_intersect($this->sapiTargets, ['embed', 'cli'])); $runnableTargets = array_values(array_intersect($this->sapiTargets, ['embed', 'cli']));
@ -2930,7 +2943,11 @@ CODE;
} }
/** @param list<string> $objectFiles */ /** @param list<string> $objectFiles */
private function linkNativeTarget(array $objectFiles, string $targetFile): string private function linkNativeTarget(
array $objectFiles,
string $targetFile,
bool $announceSuccess = true,
): string
{ {
// Windows: add the .res resource file to the link // Windows: add the .res resource file to the link
@ -2943,7 +2960,9 @@ CODE;
if ($this->hasLinkCache($objectFiles, $targetFile)) { if ($this->hasLinkCache($objectFiles, $targetFile)) {
$this->climate->darkGray('[incremental] link cache: ' . $targetFile); $this->climate->darkGray('[incremental] link cache: ' . $targetFile);
$this->climate->green('Build successful: ' . $targetFile); if ($announceSuccess) {
$this->climate->green('Build successful: ' . $targetFile);
}
return $targetFile; return $targetFile;
} }
@ -2968,7 +2987,9 @@ CODE;
} }
$this->writeLinkCache($objectFiles, $targetFile); $this->writeLinkCache($objectFiles, $targetFile);
$this->climate->green('Build successful: ' . $targetFile); if ($announceSuccess) {
$this->climate->green('Build successful: ' . $targetFile);
}
return $targetFile; return $targetFile;
} }
@ -4291,18 +4312,7 @@ CODE;
} }
} }
if (array_key_exists('entry', $cfg)) { if (array_key_exists('entry', $cfg)) {
if (!is_string($cfg['entry']) || trim($cfg['entry']) === '') { $this->configureSapiEntry($cfg['entry'], $projectDir);
$this->error('`entry` must be a non-empty PHP file path');
}
$entryPath = $this->resolvePath($cfg['entry'], $projectDir, 'Entry path');
$entry = realpath($entryPath);
if ($entry === false || !is_file($entry)) {
$this->error('Entry file does not exist: `' . $cfg['entry'] . '`');
}
if (!FileScanner::isPhpFile($entry) || str_ends_with($entry, '.stub.php')) {
$this->error('`entry` must select an executable PHP file');
}
$this->sapiEntryFile = $entry;
} }
if (!empty($cfg['sources'])) { if (!empty($cfg['sources'])) {
@ -4677,6 +4687,29 @@ CODE;
// an internal Zend module and therefore has no dynamic get_module(). // an internal Zend module and therefore has no dynamic get_module().
} }
private function configureSapiEntry(mixed $value, string $baseDirectory): void
{
if (!is_string($value) || trim($value) === '') {
$this->error('`entry` must be a non-empty PHP file path');
}
$value = trim($value);
$entryPath = $this->resolvePath($value, $baseDirectory, 'Entry path');
$entry = realpath($entryPath);
if ($entry === false || !is_file($entry)) {
$this->error('Entry file does not exist: `' . $value . '`');
}
if (!FileScanner::isPhpFile($entry) || str_ends_with($entry, '.stub.php')) {
$this->error('`entry` must select an executable PHP file');
}
$this->sapiEntryFile = $entry;
if (!in_array($entry, $this->embeddedFiles, true)) {
$this->embeddedFiles[] = $entry;
}
if (!in_array($entry, $this->embeddedPhpFiles, true)) {
$this->embeddedPhpFiles[] = $entry;
}
}
private function configureSapiTargets(string|array $value): void private function configureSapiTargets(string|array $value): void
{ {
try { try {

Loading…
Cancel
Save