feat(compiler): add SAPI build mode and enhance platform support

- Add BUILD_MODE_SAPI constant and related methods for SAPI-specific builds
- Introduce sapiTargets, sapiEntryFile, and other SAPI-related configuration options
- Enhance Android platform with allowTargetVersion parameter in buildPhpIncludePaths
- Reorganize imports in CompilerBase with better grouping and ordering
- Add isSapiBuild() and getSapiTargets() methods for SAPI detection and management
- Update build mode logic to include SAPI in embed builds
- Refine expression type checking with shorter namespace references
- Add comprehensive SAPI build tests including target configuration scenarios
- Improve class property access in tests with parent class traversal support
- Standardize namespace separator handling in various compiler operations
- Add proper escaping and formatting in generated C++ code strings
- Implement SAPI-specific project parsing and validation rules
- Update test assertions to handle new namespace separator format
- Add error handling improvements for unsupported source conditions
- Enhance type checker generator
master
韩天峰 2 weeks ago
parent 43e85b8866
commit 3b33d79109
  1. 2
      completions/tpc.bash
  2. 6
      phpunit/src/Build/EmbeddedArchiveBuilderTest.php
  3. 55
      phpunit/src/Build/OfficialPhpSourceTest.php
  4. 105
      phpunit/src/Build/SapiApplicationLinkerTest.php
  5. 24
      phpunit/src/Build/SapiBuildConfigurationTest.php
  6. 102
      phpunit/src/Build/SapiExtensionRequirementsTest.php
  7. 202
      phpunit/src/CompilerBaseApiTest.php
  8. 26
      phpunit/src/Installer/PhpBuildConfigurationTest.php
  9. 14
      src/Build/EmbeddedTableRenderer.php
  10. 14
      src/Build/NativeBuildConfigurationTrait.php
  11. 238
      src/Build/OfficialPhpSource.php
  12. 92
      src/Build/PhpSourceExtensionIndex.php
  13. 358
      src/Build/SapiApplicationLinker.php
  14. 37
      src/Build/SapiBuildConfiguration.php
  15. 71
      src/Build/SapiExtensionConfiguration.php
  16. 89
      src/Build/SapiExtensionRequirements.php
  17. 25
      src/Build/SapiPhpBuild.php
  18. 368
      src/Build/SapiPhpBuilder.php
  19. 171
      src/Build/SourcePipelineTrait.php
  20. 205
      src/CompilerBase.php
  21. 50
      src/Installer/PhpBuildConfiguration.php
  22. 7
      src/Metadata/Constants.php
  23. 2
      src/Platform/Android.php
  24. 2
      src/Platform/Ios.php
  25. 4
      src/Platform/Macos.php
  26. 2
      src/Platform/PlatformBase.php
  27. 21
      src/Platform/UnixPlatform.php
  28. 2
      src/Platform/Windows.php
  29. 481
      src/Translator.php

@ -144,7 +144,7 @@ _typephp_tpc()
return
;;
-* )
COMPREPLY=( $(compgen -W '--nano -O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version -f --force -m --mode -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 -f --force -m --mode --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") )
return
;;
esac

@ -94,6 +94,12 @@ final class EmbeddedArchiveBuilderTest extends TestCase
$unix,
);
self::assertStringNotContainsString('typephp_opcode_table_install(void) {}', $unix);
$sapi = $renderer->render($archive, '8.4.14', false, false, '/app/index.php');
self::assertStringContainsString(
'typephp_project_entry_file(void) { return "/app/index.php"; }',
$sapi,
);
}
private function write(string $name, string $contents): string

@ -0,0 +1,55 @@
<?php
namespace TypePhpTest\Build;
use PHPUnit\Framework\TestCase;
use TypePhp\Build\OfficialPhpSource;
final class OfficialPhpSourceTest extends TestCase
{
public function testDefaultCacheUsesTypePhpHomeDirectory(): void
{
self::assertSame('/home/example/.typephp', OfficialPhpSource::defaultCacheDirectory('/home/example'));
}
public function testVersionIsReadFromSourceHeader(): void
{
$directory = sys_get_temp_dir() . '/typephp-source-version-' . bin2hex(random_bytes(6));
mkdir($directory . '/main', 0777, true);
file_put_contents($directory . '/main/php_version.h', '#define PHP_VERSION "8.4.14"');
try {
self::assertSame('8.4.14', OfficialPhpSource::version($directory));
} finally {
unlink($directory . '/main/php_version.h');
rmdir($directory . '/main');
rmdir($directory);
}
}
public function testCachedMinorReleaseIsUsedWithoutNetworkAccess(): void
{
$directory = sys_get_temp_dir() . '/typephp-source-cache-' . bin2hex(random_bytes(6));
foreach (['8.4.9', '8.4.25'] as $version) {
$source = $directory . '/src/php-' . $version;
foreach (['main', 'Zend', 'ext/standard'] as $child) {
mkdir($source . '/' . $child, 0777, true);
}
foreach (['configure', 'main/php.h', 'main/php_version.h', 'Zend/zend.h', 'ext/standard/fsock.h'] as $file) {
touch($source . '/' . $file);
}
}
try {
$manager = new OfficialPhpSource($directory, static function (string $message): void {});
self::assertSame($directory . '/src/php-8.4.25', $manager->prepare('8.4'));
} finally {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iterator as $entry) {
$entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname());
}
rmdir($directory);
}
}
}

@ -0,0 +1,105 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhpTest\Build;
use PHPUnit\Framework\TestCase;
use TypePhp\Build\SapiApplicationLinker;
/**
* @internal
* @coversNothing
*/
final class SapiApplicationLinkerTest extends TestCase
{
private string $directory;
protected function setUp(): void
{
$this->directory = sys_get_temp_dir() . '/typephp-sapi-linker-' . uniqid('', true);
mkdir($this->directory . '/php/sapi/cli', 0777, true);
file_put_contents(
$this->directory . '/php/sapi/cli/php_cli.c',
<<<'C'
#include "php.h"
static zend_result cli_seek_file_begin(zend_file_handle *file_handle, char *script_file)
{
}
int main(int argc, char *argv[])
{
int ini_ignore = 0;
sapi_module_struct *sapi_module_ptr = &cli_sapi_module;
argv = save_ps_args(argc, argv);
cleanup_ps_args(argv);
}
C,
);
file_put_contents(
$this->directory . '/php/sapi/cli/php_cli_server.c',
<<<'C'
#include "php.h"
static void translate(void)
{
php_sys_stat(buf, &sb);
php_sys_stat(buf, &sb);
}
C,
);
}
protected function tearDown(): void
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iterator as $entry) {
$entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname());
}
rmdir($this->directory);
}
public function testCliAdapterPrependsEmbeddedEntryToSavedArguments(): void
{
$entry = $this->directory . '/project/index.php';
$linker = new SapiApplicationLinker(
$this->directory . '/php',
$this->directory . '/php-build',
$this->directory . '/libphpx.a',
[],
$this->directory . '/project-build',
['cli'],
$entry,
static function (string $message): void {},
);
$method = new \ReflectionMethod($linker, 'generateSapiAdapters');
$method->invoke($linker, 'sapi/cli/php_cli.lo', '');
$sources = glob($this->directory . '/project-build/cache/sapi/adapters/*/php_cli.c');
self::assertIsArray($sources);
self::assertCount(1, $sources);
$code = (string) file_get_contents($sources[0]);
self::assertStringContainsString('typephp_saved_argv = argv;', $code);
self::assertStringContainsString(
'typephp_cli_prepare_arguments(&argc, &argv, OPTIONS, &typephp_application_argv)',
$code,
);
self::assertStringContainsString('typephp_cli_release_arguments(typephp_application_argv);', $code);
self::assertStringContainsString('cleanup_ps_args(typephp_saved_argv);', $code);
self::assertStringNotContainsString('php_getopt(', $code);
self::assertStringNotContainsString('typephp_entry_script', $code);
$serverSources = glob($this->directory . '/project-build/cache/sapi/adapters/*/php_cli_server.c');
self::assertIsArray($serverSources);
self::assertCount(1, $serverSources);
$serverCode = (string) file_get_contents($serverSources[0]);
self::assertSame(2, substr_count($serverCode, 'typephp_cli_server_stat(buf, &sb)'));
self::assertStringContainsString('typephp_embedded_path_kind(path)', $serverCode);
self::assertStringNotContainsString('php_sys_stat(buf, &sb)', $serverCode);
}
}

@ -0,0 +1,24 @@
<?php
namespace TypePhpTest\Build;
use PHPUnit\Framework\TestCase;
use TypePhp\Build\SapiBuildConfiguration;
final class SapiBuildConfigurationTest extends TestCase
{
public function testTargetsAcceptSingleBothAndLists(): void
{
self::assertSame(['cli'], SapiBuildConfiguration::parseTargets('cli'));
self::assertSame(['cli', 'fpm'], SapiBuildConfiguration::parseTargets('both'));
self::assertSame(['fpm', 'cli'], SapiBuildConfiguration::parseTargets(['fpm', 'cli', 'fpm']));
self::assertSame(['cli', 'fpm'], SapiBuildConfiguration::parseTargets('cli, fpm'));
}
public function testInvalidTargetIsRejected(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Expected cli, fpm, or both');
SapiBuildConfiguration::parseTargets('apache');
}
}

@ -0,0 +1,102 @@
<?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\PhpSourceExtensionIndex;
use TypePhp\Build\SapiExtensionConfiguration;
use TypePhp\Build\SapiExtensionRequirements;
/**
* @internal
* @coversNothing
*/
final class SapiExtensionRequirementsTest extends TestCase
{
private string $directory;
protected function setUp(): void
{
$this->directory = sys_get_temp_dir() . '/typephp-sapi-ext-' . bin2hex(random_bytes(6));
mkdir($this->directory, 0777, true);
}
protected function tearDown(): void
{
if (is_dir($this->directory)) {
exec('rm -rf ' . escapeshellarg($this->directory));
}
}
public function testComposerRequireExtensionsAreNormalizedAndSorted(): void
{
mkdir($this->directory . '/vendor/package/name', 0777, true);
file_put_contents($this->directory . '/vendor/autoload.php', '<?php');
$embedded = $this->directory . '/vendor/package/name/file.php';
file_put_contents($embedded, '<?php');
file_put_contents($this->directory . '/composer.json', json_encode([
'require' => [
'php' => '^8.4',
'ext-Zend-OPcache' => '*',
'ext-pdo_mysql' => '*',
],
'require-dev' => ['ext-xdebug' => '*'],
], JSON_THROW_ON_ERROR));
self::assertSame(
['opcache', 'pdo-mysql'],
SapiExtensionRequirements::fromEmbeddedVendorFiles([$embedded]),
);
}
public function testComposerOutsideEmbeddedVendorIsIgnored(): void
{
file_put_contents($this->directory . '/composer.json', json_encode([
'require' => ['ext-curl' => '*'],
], JSON_THROW_ON_ERROR));
$application = $this->directory . '/index.php';
file_put_contents($application, '<?php');
self::assertSame([], SapiExtensionRequirements::fromEmbeddedVendorFiles([$application]));
}
public function testPhpSourceConfigureSwitchesFollowExtensionConfig(): void
{
mkdir($this->directory . '/ext/curl', 0777, true);
mkdir($this->directory . '/ext/mbstring', 0777, true);
file_put_contents($this->directory . '/ext/curl/config.m4', 'PHP_ARG_WITH([curl], [curl])');
file_put_contents($this->directory . '/ext/mbstring/config.m4', 'PHP_ARG_ENABLE([mbstring], [mb])');
self::assertSame(
['--with-curl', '--enable-mbstring'],
SapiExtensionConfiguration::configureOptions(
$this->directory,
['curl', 'Core', 'mbstring'],
),
);
}
public function testPhpSourceStubsProvideFunctionAndClassOwnership(): void
{
mkdir($this->directory . '/ext/example', 0777, true);
file_put_contents($this->directory . '/ext/example/example.stub.php', <<<'PHP'
<?php
namespace {
function example_open(): void {}
}
namespace Example {
class Client {}
}
PHP);
$index = PhpSourceExtensionIndex::forSource($this->directory);
self::assertSame('example', $index->functionExtension('example_open'));
self::assertSame('example', $index->classExtension('Example\Client'));
}
}

@ -1,16 +1,26 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Tests;
use PHPUnit\Framework\TestCase;
use TypePhp\CompilerTest;
use TypePhp\CompilerBase;
use TypePhp\Metadata\Constants;
use TypePhp\Type;
use TypePhp\CompilerTest;
use TypePhp\Exception\TestError;
use TypePhp\Metadata\Constants;
use TypePhp\Platform\Macos;
use TypePhp\Platform\Windows;
use TypePhp\Type;
/**
* @internal
* @coversNothing
*/
class CompilerBaseApiTest extends TestCase
{
private string $testDir;
@ -60,7 +70,11 @@ class CompilerBaseApiTest extends TestCase
private function getPropertyValue(string $name): mixed
{
$prop = $this->ref->getProperty($name);
$class = $this->ref;
while (!$class->hasProperty($name) && ($parent = $class->getParentClass()) !== false) {
$class = $parent;
}
$prop = $class->getProperty($name);
$prop->setAccessible(true);
return $prop->getValue($this->compiler);
}
@ -153,7 +167,7 @@ PHP);
foreach (['classMap', 'persistentClassMap', 'funcMap', 'persistentFuncMap', 'persistentPropMap'] as $map) {
$this->assertSame([], $this->getPropertyValue($map), $map);
}
$defaults = $this->compiler->getClassDef('CachePhase\\Defaults');
$defaults = $this->compiler->getClassDef('CachePhase\Defaults');
$this->assertNotNull($defaults);
$this->assertInstanceOf(
\PhpParser\Node\Expr\ClassConstFetch::class,
@ -169,7 +183,7 @@ PHP);
$this->assertSame([], $this->getPropertyValue('classMap'));
$this->assertArrayHasKey(
'CachePhase\\LateClass',
'CachePhase\LateClass',
$this->getPropertyValue('persistentClassMap'),
);
$this->assertNotSame('', $defaults->getConstant('VALUE')->value);
@ -431,7 +445,7 @@ PHP);
public function testUnqualifiedRuntimeConstantUsesNamespaceFallback(): void
{
$this->setPropertyValue('namespace', 'App\\Worker');
$this->setPropertyValue('namespace', 'App\Worker');
$this->setPropertyValue('noLiteralStrings', true);
$code = $this->invokeMethod(
@ -440,23 +454,23 @@ PHP);
);
$this->assertStringNotContainsString('php::fn::defined(', $code);
$this->assertStringContainsString('App\\\\Worker\\\\COMPOSER_PATH', $code);
$this->assertStringContainsString('App\\\Worker\\\COMPOSER_PATH', $code);
$this->assertStringEndsWith(', php::ConstantLookup::UnqualifiedInNamespace)', $code);
$this->assertStringNotContainsString('php::constant(nullptr,', $code);
}
public function testQualifiedRuntimeConstantDoesNotUseGlobalFallback(): void
{
$this->setPropertyValue('namespace', 'App\\Worker');
$this->setPropertyValue('namespace', 'App\Worker');
$this->setPropertyValue('noLiteralStrings', true);
$code = $this->invokeMethod(
'parseConstFetch',
new \PhpParser\Node\Expr\ConstFetch(new \PhpParser\Node\Name('Config\\PATH'))
new \PhpParser\Node\Expr\ConstFetch(new \PhpParser\Node\Name('Config\PATH'))
);
$this->assertStringNotContainsString('php::fn::defined(', $code);
$this->assertStringContainsString('App\\\\Worker\\\\Config\\\\PATH', $code);
$this->assertStringContainsString('App\\\Worker\\\Config\\\PATH', $code);
}
public function testInlineStringArrayKeyUsesZendStringPointer(): void
@ -685,7 +699,7 @@ YAML);
"zend_module_entry typephp_app_module_entry = {\n"
. " STANDARD_MODULE_HEADER_EX,\n"
. " nullptr,\n"
. " typephp_app_module_deps,",
. ' typephp_app_module_deps,',
$extension,
);
}
@ -719,8 +733,8 @@ YAML);
'php_info_print_table_header(2, "typephp_metadata_demo support", "enabled");',
$extension,
);
$this->assertStringContainsString('php_info_print_table_row(2, "Maintainer", "TypePHP \\"Core\\" Team");', $extension);
$this->assertStringContainsString('php_info_print_table_row(2, "Description", "Native PHP\\\\C++ extension");', $extension);
$this->assertStringContainsString('php_info_print_table_row(2, "Maintainer", "TypePHP \"Core\" Team");', $extension);
$this->assertStringContainsString('php_info_print_table_row(2, "Description", "Native PHP\\\C++ extension");', $extension);
$this->assertStringContainsString('php_info_print_table_row(2, "Stable", "true");', $extension);
$this->assertStringContainsString(
" PHP_MINFO(typephp_metadata_demo),\n"
@ -883,6 +897,96 @@ YAML, 'myproject.yml', 'examples/tetris-sdl');
$this->assertSame('demo.so', $this->invokeMethod('getTargetFileName'));
}
public function testSingleSapiTargetUsesConfiguredOutputName(): void
{
$this->compiler->setOutputPath($this->testDir . '/build/app');
$this->invokeMethod('configureSapiTargets', 'fpm');
$this->assertSame(
['fpm' => $this->testDir . '/build/app'],
$this->compiler->getSapiOutputFiles(),
);
}
public function testBothSapiTargetsHaveExplicitTargetSuffixes(): void
{
$this->compiler->setOutputPath($this->testDir . '/build/app');
$this->invokeMethod('configureSapiTargets', 'both');
$this->assertSame(
[
'cli' => $this->testDir . '/build/app-cli',
'fpm' => $this->testDir . '/build/app-fpm',
],
$this->compiler->getSapiOutputFiles(),
);
}
public function testSapiBuildModeDefaultsToCliTarget(): void
{
$this->compiler->setBuildMode(CompilerBase::BUILD_MODE_SAPI);
$this->assertSame(['cli'], $this->compiler->getSapiTargets());
$this->assertTrue($this->compiler->isSapiBuild());
}
public function testSapiCliEntryIsEmbeddedAndExcludedFromAotSources(): void
{
$projectFile = $this->createProjectFile(<<<'YAML'
sapi: cli
entry: main.php
sources:
- main.php
YAML);
$files = $this->invokeMethod('parseProjectYaml', $projectFile);
$entry = realpath(dirname($projectFile) . '/main.php');
$this->assertSame([], $files);
$this->assertSame($entry, $this->getPropertyValue('sapiEntryFile'));
$this->assertSame([$entry], $this->getPropertyValue('embeddedFiles'));
$this->assertSame([$entry], $this->getPropertyValue('embeddedPhpFiles'));
$this->invokeMethod('validateLoadedProjectConfiguration');
}
public function testSapiCliRequiresEntry(): void
{
$projectFile = $this->createProjectFile(<<<'YAML'
sapi: cli
sources:
- main.php
YAML);
$this->invokeMethod('parseProjectYaml', $projectFile);
$this->expectException(TestError::class);
$this->expectExceptionMessage('CLI SAPI builds require an `entry` PHP file');
$this->invokeMethod('validateLoadedProjectConfiguration');
}
public function testSapiFpmDoesNotRequireCliEntry(): void
{
$projectFile = $this->createProjectFile(<<<'YAML'
sapi: fpm
sources:
- main.php
YAML);
$this->invokeMethod('parseProjectYaml', $projectFile);
$this->invokeMethod('validateLoadedProjectConfiguration');
$this->assertSame(['fpm'], $this->compiler->getSapiTargets());
}
public function testProjectWithoutAotSourcesUsesRuntimeDeclarationHeader(): void
{
$this->compiler->setTargetName('opcode_only');
$headers = $this->invokeMethod('genExtensionIncludeHeaderFiles');
$this->assertStringContainsString('php_opcode_only_runtime_decl.h', $headers);
$this->assertStringNotContainsString('php_opcode_only_func_decl.h', $headers);
$this->assertStringNotContainsString('php_opcode_only_data_decl.h', $headers);
}
public function testGeneratedZendModuleAndProjectNamespaceUseDistinctPrefixes(): void
{
$this->compiler->setTargetName('demo');
@ -1252,7 +1356,7 @@ sources:
if: PHP_OS_FAMILY >= "Linux"
YAML);
$this->expectException(\TypePhp\Exception\TestError::class);
$this->expectException(TestError::class);
$this->expectExceptionMessage('Unsupported source condition');
$this->invokeMethod('parseProjectYaml', $projectFile);
@ -1266,7 +1370,7 @@ sources:
if: PHP_VERSION
YAML);
$this->expectException(\TypePhp\Exception\TestError::class);
$this->expectException(TestError::class);
$this->expectExceptionMessage('Unsupported source condition');
$this->invokeMethod('parseProjectYaml', $projectFile);
@ -1280,7 +1384,7 @@ sources:
if: PHP_VERSION_ID >= getenv("MIN_PHP")
YAML);
$this->expectException(\TypePhp\Exception\TestError::class);
$this->expectException(TestError::class);
$this->expectExceptionMessage('Unsupported source condition');
$this->invokeMethod('parseProjectYaml', $projectFile);
@ -1724,7 +1828,7 @@ YAML);
'TYPEPHP_COLD_ATTRIBUTE php::Str php_libraryapi__counter__label(',
$phpCpp,
);
$provider = $this->invokeMethod('getClass', 'LibraryApi\\InternalStringExtension');
$provider = $this->invokeMethod('getClass', 'LibraryApi\InternalStringExtension');
$this->assertSame(Type::STR, $provider->methodsForTarget);
$stubFile = $this->compiler->genLibraryImportStub($files);
@ -1736,15 +1840,15 @@ YAML);
$this->assertStringContainsString('public const int STEP = 2;', $stub);
$this->assertStringContainsString('public int $value = 1;', $stub);
$this->assertStringContainsString('#[\Constructor, \Getter, \Setter, \With]', $stub);
$this->assertStringContainsString("#[\Printer(fields: ['value', 'doubled'])]", $stub);
$this->assertStringContainsString("#[\Arrayable(['value'])]", $stub);
$this->assertStringContainsString("#[\\Printer(fields: ['value', 'doubled'])]", $stub);
$this->assertStringContainsString("#[\\Arrayable(['value'])]", $stub);
$this->assertStringContainsString('#[\NotNull, \Validate(FILTER_VALIDATE_EMAIL)]', $stub);
$this->assertStringContainsString('#[\MustUse, \Cold]', $stub);
$this->assertStringContainsString('#[\MustUse, \Hot]', $stub);
$this->assertStringContainsString('#[\Override]', $stub);
$this->assertStringContainsString('#[\Immutable]', $stub);
$this->assertMatchesRegularExpression(
'/function inspect\(\s*#\[\\\\Immutable\]\s*\\\\LibraryApi\\\\Counter \$counter\s*\): int/s',
'/function inspect\(\s*#\[\\\Immutable\]\s*\\\LibraryApi\\\Counter \$counter\s*\): int/s',
$stub,
);
$this->assertMatchesRegularExpression(
@ -1753,7 +1857,7 @@ YAML);
);
$this->assertStringContainsString('function add(int $amount = self::STEP): int', $stub);
$this->assertMatchesRegularExpression(
'/function label\(\s*#\[\\\\NotNull, \\\\Validate\(FILTER_VALIDATE_EMAIL\)\]\s*string \$value\s*\): string/s',
'/function label\(\s*#\[\\\NotNull, \\\Validate\(FILTER_VALIDATE_EMAIL\)\]\s*string \$value\s*\): string/s',
$stub,
);
$this->assertStringContainsString('function twice(int $value): int', $stub);
@ -2092,8 +2196,8 @@ YAML);
public function testGetNamespacedClassNameFullyQualified(): void
{
$this->assertEquals(
'App\\Entity\\User',
$this->compiler->getNamespacedClassName('\\App\\Entity\\User')
'App\Entity\User',
$this->compiler->getNamespacedClassName('\App\Entity\User')
);
}
@ -2103,19 +2207,19 @@ YAML);
public function testGetNamespacedClassNameWithUseAlias(): void
{
$this->setPropertyValue('useAliases', ['user' => 'App\\Entity\\User']);
$this->setPropertyValue('useAliases', ['user' => 'App\Entity\User']);
$this->assertEquals(
'App\\Entity\\User',
'App\Entity\User',
$this->compiler->getNamespacedClassName('User')
);
}
public function testGetNamespacedClassNameWithUseAliasSubNamespace(): void
{
$this->setPropertyValue('useAliases', ['entity' => 'App\\Entity']);
$this->setPropertyValue('useAliases', ['entity' => 'App\Entity']);
$this->assertEquals(
'App\\Entity\\User',
$this->compiler->getNamespacedClassName('Entity\\User')
'App\Entity\User',
$this->compiler->getNamespacedClassName('Entity\User')
);
}
@ -2123,25 +2227,25 @@ YAML);
{
$use = new \PhpParser\Node\Stmt\Use_([
new \PhpParser\Node\UseItem(
new \PhpParser\Node\Name('Vendor\\Package\\Notes'),
new \PhpParser\Node\Name('Vendor\Package\Notes'),
new \PhpParser\Node\Identifier('NotesFactory'),
),
]);
$this->invokeMethod('parseUse', $use);
$this->setPropertyValue('namespace', 'Application\\Api');
$this->setPropertyValue('namespace', 'Application\Api');
$this->assertSame([], $this->getPropertyValue('useNamespaces'));
$this->assertSame(
['notesfactory' => 'Vendor\\Package\\Notes'],
['notesfactory' => 'Vendor\Package\Notes'],
$this->getPropertyValue('useAliases'),
);
$this->assertSame(
'Vendor\\Package\\Notes',
'Vendor\Package\Notes',
$this->compiler->getNamespacedClassName('NOTESFACTORY'),
);
$this->assertSame(
'Application\\Api\\Notes',
'Application\Api\Notes',
$this->compiler->getNamespacedClassName('Notes'),
);
}
@ -2152,21 +2256,21 @@ YAML);
public function testGetNamespacedClassNameWithUseNamespace(): void
{
$this->setPropertyValue('useNamespaces', ['App\\Entity']);
$this->setPropertyValue('useNamespaces', ['App\Entity']);
// The last segment of 'App\Entity' is 'Entity', matching input 'Entity'
$this->assertEquals(
'App\\Entity',
'App\Entity',
$this->compiler->getNamespacedClassName('Entity')
);
}
public function testGetNamespacedClassNameWithUseNamespaceSub(): void
{
$this->setPropertyValue('useNamespaces', ['App\\Entity']);
$this->setPropertyValue('useNamespaces', ['App\Entity']);
// 'Entity\User' - first part 'Entity' matches the last part of 'App\Entity'
$this->assertEquals(
'App\\Entity\\User',
$this->compiler->getNamespacedClassName('Entity\\User')
'App\Entity\User',
$this->compiler->getNamespacedClassName('Entity\User')
);
}
@ -2176,12 +2280,12 @@ YAML);
public function testGetNamespacedClassNameWithCurrentNamespace(): void
{
$this->setPropertyValue('namespace', 'App\\Service');
$this->setPropertyValue('namespace', 'App\Service');
// No matching alias or use namespace
$this->setPropertyValue('useAliases', []);
$this->setPropertyValue('useNamespaces', []);
$this->assertEquals(
'App\\Service\\MyClass',
'App\Service\MyClass',
$this->compiler->getNamespacedClassName('MyClass')
);
}
@ -2203,11 +2307,11 @@ YAML);
public function testGetNamespacedClassNameAliasPriority(): void
{
$this->setPropertyValue('useAliases', ['user' => 'App\\Models\\User']);
$this->setPropertyValue('useNamespaces', ['App\\Controllers']);
$this->setPropertyValue('useAliases', ['user' => 'App\Models\User']);
$this->setPropertyValue('useNamespaces', ['App\Controllers']);
// Alias should be checked first
$this->assertEquals(
'App\\Models\\User',
'App\Models\User',
$this->compiler->getNamespacedClassName('User')
);
}
@ -2219,18 +2323,18 @@ YAML);
public function testGetNamespacedFuncNameFullyQualified(): void
{
$this->assertEquals(
'App\\Lib\\helper_func',
$this->compiler->getNamespacedFuncName('\\App\\Lib\\helper_func')
'App\Lib\helper_func',
$this->compiler->getNamespacedFuncName('\App\Lib\helper_func')
);
}
public function testGetNamespacedFuncNameWithUseFunction(): void
{
$this->setPropertyValue('useFunctions', [
'helper_func' => 'App\\Lib\\helper_func',
'helper_func' => 'App\Lib\helper_func',
]);
$this->assertEquals(
'App\\Lib\\helper_func',
'App\Lib\helper_func',
$this->compiler->getNamespacedFuncName('helper_func')
);
}
@ -2250,7 +2354,7 @@ YAML);
public function testGetNamespacedFuncNameNotInUseFunctions(): void
{
$this->setPropertyValue('useFunctions', ['other' => 'Some\\Ns\\other']);
$this->setPropertyValue('useFunctions', ['other' => 'Some\Ns\other']);
$this->assertEquals(
'my_func',
$this->compiler->getNamespacedFuncName('my_func')

@ -138,6 +138,32 @@ final class PhpBuildConfigurationTest extends TestCase
self::assertContains('--enable-mbstring', $options);
}
public function testSapiDerivationBuildsStaticCliAndSelectedFpm(): void
{
$options = PhpBuildConfiguration::deriveSapi(
[
'--prefix=/usr',
'--enable-embed=shared',
'--enable-opcache=shared',
'--with-curl=shared',
'--enable-mbstring',
'--with-pear',
],
'/tmp/typephp-sapi',
['cli', 'fpm'],
);
self::assertContains('--prefix=/tmp/typephp-sapi', $options);
self::assertContains('--enable-cli', $options);
self::assertContains('--enable-fpm', $options);
self::assertContains('--enable-opcache', $options);
self::assertContains('--with-curl', $options);
self::assertContains('--without-pear', $options);
self::assertNotContains('--enable-embed=shared', $options);
self::assertNotContains('--enable-opcache=shared', $options);
self::assertNotContains('--with-curl=shared', $options);
}
public function testParseShellWordsRejectsIncompleteInput(): void
{
$this->expectException(\InvalidArgumentException::class);

@ -10,7 +10,13 @@ namespace TypePhp\Build;
final class EmbeddedTableRenderer
{
public function render(EmbeddedArchive $archive, string $phpVersion, bool $windows): string
public function render(
EmbeddedArchive $archive,
string $phpVersion,
bool $windows,
bool $emitEmptyRuntimeHooks = true,
?string $entryFile = null,
): string
{
$code = '#include <typephp_opcode_table.h>' . PHP_EOL;
if (!$archive->isEmpty()) {
@ -32,7 +38,11 @@ final class EmbeddedTableRenderer
);
$version = json_encode($phpVersion, JSON_THROW_ON_ERROR);
$code .= 'extern "C" const char *typephp_project_php_version(void) { return ' . $version . '; }' . PHP_EOL;
if ($archive->isEmpty()) {
$entry = $entryFile === null
? 'nullptr'
: json_encode($entryFile, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$code .= 'extern "C" const char *typephp_project_entry_file(void) { return ' . $entry . '; }' . PHP_EOL;
if ($archive->isEmpty() && $emitEmptyRuntimeHooks) {
$code .= 'extern "C" void typephp_opcode_table_install(void) {}' . PHP_EOL;
$code .= 'extern "C" void typephp_opcode_table_uninstall(void) {}' . PHP_EOL;
}

@ -177,8 +177,20 @@ trait NativeBuildConfigurationTrait
$includePaths = array_merge($includePaths, $phpSdkPaths);
} else {
// Linux/macOS
$phpPaths = $platform->buildPhpIncludePaths($this->getPhpDir());
$phpPaths = $platform->buildPhpIncludePaths($this->getPhpDir(), $this->isSapiBuild());
$includePaths = array_merge($includePaths, $phpPaths);
if ($this->isSapiBuild() && $this->sapiPhpBuildDirectory !== null) {
$makefile = $this->sapiPhpBuildDirectory . '/Makefile';
$contents = is_file($makefile) ? (string) file_get_contents($makefile) : '';
if (preg_match('/^INCLUDES[ \t]*=[ \t]*(.*)$/m', $contents, $match) === 1) {
preg_match_all('/(?:^|\s)-I([^\s]+)/', $match[1], $paths);
foreach ($paths[1] ?? [] as $path) {
if (is_dir($path)) {
$includePaths[] = $path;
}
}
}
}
// Bundled mpdecimal header directories
$includePaths[] = $this->getPhpxDir() . '/thirdparty/mpdecimal/libmpdec';
$includePaths[] = $this->getPhpxDir() . '/thirdparty/mpdecimal/libmpdec++';

@ -0,0 +1,238 @@
<?php
namespace TypePhp\Build;
final class OfficialPhpSource
{
private const string RELEASE_API = 'https://www.php.net/releases/index.php?json=1&version=%s&max=100';
private readonly \Closure $output;
/** @param callable(string):void $output */
public function __construct(
private readonly string $cacheDirectory,
callable $output,
) {
$this->output = \Closure::fromCallable($output);
}
public static function defaultCacheDirectory(?string $home = null): string
{
$home ??= getenv('HOME') ?: '';
if ($home === '') {
throw new \RuntimeException('Cannot determine the PHP source cache because HOME is not set');
}
return rtrim($home, '/\\') . '/.typephp';
}
public function prepare(string $requestedVersion): string
{
$cached = $this->findCachedSource($requestedVersion);
if ($cached !== null) {
return $cached;
}
$release = $this->resolveRelease($requestedVersion);
$this->mkdir($this->cacheDirectory);
$lock = fopen($this->cacheDirectory . '/download.lock', 'c+');
if ($lock === false || !flock($lock, LOCK_EX)) {
throw new \RuntimeException('Unable to lock the PHP source cache: ' . $this->cacheDirectory);
}
try {
return $this->prepareLocked($release);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
private function findCachedSource(string $requestedVersion): ?string
{
if (preg_match('/^(8\.(?:4|5))(?:\.0)?$/', $requestedVersion, $match) === 1) {
$pattern = $this->cacheDirectory . '/src/php-' . $match[1] . '.*';
} elseif (preg_match('/^8\.(?:4|5)\.\d+$/', $requestedVersion) === 1) {
$pattern = $this->cacheDirectory . '/src/php-' . $requestedVersion;
} else {
return null;
}
$candidates = [];
foreach (glob($pattern, GLOB_ONLYDIR) ?: [] as $source) {
if ($this->isComplete($source)) {
$candidates[] = $source;
}
}
usort($candidates, static fn(string $left, string $right): int => version_compare(
substr(basename($right), 4),
substr(basename($left), 4),
));
return $candidates[0] ?? null;
}
/** @return array{version:string,filename:string,sha256:string,url:string} */
private function resolveRelease(string $requestedVersion): array
{
if (preg_match('/^(8\.(?:4|5))(?:\.0)?$/', $requestedVersion, $match) === 1) {
$branch = $match[1];
$exactVersion = null;
} elseif (preg_match('/^(8\.(?:4|5))\.\d+$/', $requestedVersion, $match) === 1) {
$branch = $match[1];
$exactVersion = $requestedVersion;
} else {
throw new \InvalidArgumentException(
"Unsupported PHP source version `{$requestedVersion}`. Expected 8.4, 8.5, or a stable patch release.",
);
}
$data = json_decode(
$this->downloadText(sprintf(self::RELEASE_API, rawurlencode($branch))),
true,
flags: JSON_THROW_ON_ERROR,
);
if (!is_array($data)) {
throw new \RuntimeException('Invalid release list returned by PHP.net');
}
if ($exactVersion === null) {
$versions = array_keys($data);
usort($versions, static fn(string $left, string $right): int => version_compare($right, $left));
$exactVersion = current(array_values(array_filter(
$versions,
static fn(string $version): bool => preg_match('/^' . preg_quote($branch, '/') . '\.\d+$/', $version) === 1,
))) ?: null;
}
if ($exactVersion === null || !isset($data[$exactVersion]) || !is_array($data[$exactVersion])) {
throw new \RuntimeException("PHP {$requestedVersion} was not found in the official release list");
}
foreach ($data[$exactVersion]['source'] ?? [] as $source) {
$filename = (string) ($source['filename'] ?? '');
$sha256 = (string) ($source['sha256'] ?? '');
if (str_ends_with($filename, '.tar.xz') && $sha256 !== '') {
return [
'version' => $exactVersion,
'filename' => $filename,
'sha256' => $sha256,
'url' => 'https://www.php.net/distributions/' . rawurlencode($filename),
];
}
}
throw new \RuntimeException("PHP {$exactVersion} has no verified tar.xz source archive");
}
/** @param array{version:string,filename:string,sha256:string,url:string} $release */
private function prepareLocked(array $release): string
{
$sourceDirectory = $this->cacheDirectory . '/src';
$this->mkdir($sourceDirectory);
$source = $sourceDirectory . '/php-' . $release['version'];
if ($this->isComplete($source)) {
return $source;
}
$archiveDirectory = $this->cacheDirectory . '/archives';
$this->mkdir($archiveDirectory);
$archive = $archiveDirectory . '/' . $release['filename'];
if (!is_file($archive) || hash_file('sha256', $archive) !== $release['sha256']) {
($this->output)('Downloading PHP ' . $release['version'] . ' source from php.net');
$temporary = $archive . '.part-' . bin2hex(random_bytes(6));
try {
$this->downloadFile($release['url'], $temporary);
if (hash_file('sha256', $temporary) !== $release['sha256']) {
throw new \RuntimeException('PHP source archive SHA-256 verification failed');
}
if (!rename($temporary, $archive)) {
throw new \RuntimeException("Unable to cache PHP source archive: {$archive}");
}
} finally {
if (is_file($temporary)) {
@unlink($temporary);
}
}
}
if (is_dir($source) && !$this->isComplete($source)) {
throw new \RuntimeException("Incomplete PHP source cache must be removed: {$source}");
}
($this->output)('Extracting PHP ' . $release['version'] . ' source into ' . $sourceDirectory);
$this->run(['tar', '-xJf', $archive, '-C', $sourceDirectory]);
if (!$this->isComplete($source)) {
throw new \RuntimeException("PHP source archive did not produce a complete source tree: {$source}");
}
return $source;
}
private function isComplete(string $source): bool
{
foreach (['configure', 'main/php.h', 'main/php_version.h', 'Zend/zend.h', 'ext/standard/fsock.h'] as $file) {
if (!is_file($source . '/' . $file)) {
return false;
}
}
return true;
}
public static function version(string $source): string
{
$header = rtrim($source, '/\\') . '/main/php_version.h';
$contents = is_file($header) ? file_get_contents($header) : false;
if (!is_string($contents)
|| preg_match('/^#define\s+PHP_VERSION\s+"([^"]+)"/m', $contents, $match) !== 1) {
throw new \RuntimeException("Cannot determine PHP source version from {$header}");
}
return $match[1];
}
private function downloadText(string $url): string
{
$context = stream_context_create(['http' => ['timeout' => 30, 'user_agent' => 'TypePHP/tpc']]);
$contents = @file_get_contents($url, false, $context);
if (is_string($contents)) {
return $contents;
}
$curl = trim((string) shell_exec('command -v curl 2>/dev/null'));
if ($curl === '') {
throw new \RuntimeException("Unable to download {$url}; enable allow_url_fopen or install curl");
}
return $this->capture([$curl, '--fail', '--location', '--retry', '3', $url]);
}
private function downloadFile(string $url, string $target): void
{
$curl = trim((string) shell_exec('command -v curl 2>/dev/null'));
if ($curl !== '') {
$this->run([$curl, '--fail', '--location', '--retry', '3', '--output', $target, $url]);
return;
}
$contents = $this->downloadText($url);
if (file_put_contents($target, $contents) === false) {
throw new \RuntimeException("Unable to write {$target}");
}
}
/** @param list<string> $command */
private function run(array $command): void
{
$process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes);
if (!is_resource($process) || proc_close($process) !== 0) {
throw new \RuntimeException('Command failed: ' . implode(' ', $command));
}
}
/** @param list<string> $command */
private function capture(array $command): string
{
$process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
throw new \RuntimeException('Unable to run command: ' . implode(' ', $command));
}
$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(trim($stderr));
}
return $stdout;
}
private function mkdir(string $directory): void
{
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new \RuntimeException("Cannot create PHP source cache directory: {$directory}");
}
}
}

@ -0,0 +1,92 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Build;
use PhpParser\Node;
use PhpParser\Node\Stmt;
use PhpParser\ParserFactory;
final class PhpSourceExtensionIndex
{
/** @var array<string, self> */
private static array $instances = [];
/** @var array<string, string> */
private array $functions = [];
/** @var array<string, string> */
private array $classes = [];
public static function forSource(string $sourceDirectory): self
{
return self::$instances[$sourceDirectory] ??= new self($sourceDirectory);
}
public function functionExtension(string $function): ?string
{
return $this->functions[strtolower(ltrim($function, '\\'))] ?? null;
}
public function classExtension(string $class): ?string
{
return $this->classes[strtolower(ltrim($class, '\\'))] ?? null;
}
private function __construct(string $sourceDirectory)
{
$parser = (new ParserFactory())->createForNewestSupportedVersion();
foreach (glob($sourceDirectory . '/ext/*') ?: [] as $extensionDirectory) {
if (!is_dir($extensionDirectory)) {
continue;
}
$extension = basename($extensionDirectory);
foreach (glob($extensionDirectory . '/*.stub.php') ?: [] as $stub) {
try {
$statements = $parser->parse((string) file_get_contents($stub));
} catch (\Throwable) {
continue;
}
if (is_array($statements)) {
$this->indexStatements($statements, '', $extension);
}
}
}
}
/** @param list<Node\Stmt> $statements */
private function indexStatements(array $statements, string $namespace, string $extension): void
{
foreach ($statements as $statement) {
if ($statement instanceof Stmt\Namespace_) {
$this->indexStatements(
$statement->stmts,
$statement->name?->toString() ?? '',
$extension,
);
continue;
}
if ($statement instanceof Stmt\Function_) {
$this->functions[$this->qualified($namespace, $statement->name->toString())] = $extension;
continue;
}
if ($statement instanceof Stmt\ClassLike && $statement->name !== null) {
$this->classes[$this->qualified($namespace, $statement->name->toString())] = $extension;
continue;
}
if (property_exists($statement, 'stmts') && is_array($statement->stmts)) {
$this->indexStatements($statement->stmts, $namespace, $extension);
}
}
}
private function qualified(string $namespace, string $name): string
{
return strtolower($namespace === '' ? $name : $namespace . '\\' . $name);
}
}

@ -0,0 +1,358 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Build;
final class SapiApplicationLinker
{
/** @param \Closure(string):void $output */
public function __construct(
private readonly string $phpSourceDirectory,
private readonly string $phpBuildDirectory,
private readonly string $phpxArchive,
/** @var array<string, string> */
private readonly array $sapiArchives,
private readonly string $buildDirectory,
private readonly array $targets,
private readonly ?string $entryFile,
private readonly \Closure $output,
) {
}
/**
* @param list<string> $projectObjects
* @param array<string, string> $outputs
*/
public function link(array $projectObjects, array $outputs): string
{
$makefile = $this->phpBuildDirectory . '/Makefile';
if (!is_file($makefile)) {
throw new \RuntimeException("Private PHP Makefile does not exist: {$makefile}");
}
$variables = $this->readMakeVariables($makefile, [
'PHP_CLI_OBJS', 'PHP_FPM_OBJS', 'EXTRA_LIBS', 'CXX',
]);
$cxx = trim($variables['CXX'] ?? '') ?: 'c++';
$binaryObjects = implode(' ', [...$projectObjects, $this->phpxArchive]);
$extraLibraries = trim(($variables['EXTRA_LIBS'] ?? '') . ' -lgmp -lgmpxx -lmpfr');
$cliObjects = $variables['PHP_CLI_OBJS'] ?? '';
$fpmObjects = $variables['PHP_FPM_OBJS'] ?? '';
$adapterMakefile = $this->generateSapiAdapters($cliObjects, $fpmObjects);
$adapterDirectory = $this->adapterDirectory();
if (in_array('cli', $this->targets, true)) {
$cliObjects = $this->replaceObject(
$cliObjects,
'sapi/cli/php_cli.lo',
$adapterDirectory . '/php_cli.lo',
);
$cliObjects = $this->replaceObject(
$cliObjects,
'sapi/cli/php_cli_server.lo',
$adapterDirectory . '/php_cli_server.lo',
);
}
if (in_array('fpm', $this->targets, true)) {
$fpmObjects = $this->replaceObject(
$fpmObjects,
'sapi/fpm/fpm/fpm_main.lo',
$adapterDirectory . '/fpm_main.lo',
);
}
$this->run([
'make', '-f', $adapterMakefile, '-j1',
...array_map(
fn (string $target): string => 'typephp-' . $target . '-adapter',
$this->targets,
),
], $this->phpBuildDirectory);
foreach ($this->targets as $target) {
$output = $outputs[$target] ?? throw new \RuntimeException("Missing {$target} output path");
$linkOutput = $this->absoluteOutputPath($output);
$runtimeArchive = $this->sapiArchives[$target]
?? throw new \RuntimeException("Missing cached PHP {$target} runtime archive");
$this->mkdir(dirname($linkOutput));
$arguments = [
'make', '-j1',
'CC=' . $cxx,
'CXX=' . $cxx,
'PHP_BINARY_OBJS=' . $binaryObjects,
'PHP_GLOBAL_OBJS=',
'EXTRA_LIBS=' . $extraLibraries,
];
if ($target === 'cli') {
$arguments[] = 'PHP_CLI_OBJS=' . $this->onlyEntryObjects(
$cliObjects,
['php_cli.lo', 'php_cli_server.lo'],
)
. ' ' . $runtimeArchive;
$arguments[] = 'SAPI_CLI_PATH=' . $linkOutput;
$arguments[] = 'cli';
} else {
$arguments[] = 'PHP_FPM_OBJS=' . $this->onlyEntryObjects($fpmObjects, ['fpm_main.lo'])
. ' ' . $runtimeArchive;
$arguments[] = 'SAPI_FPM_PATH=' . $linkOutput;
$arguments[] = 'fpm';
}
($this->output)('Linking self-contained PHP ' . strtoupper($target) . ': ' . $output);
$this->run($arguments, $this->phpBuildDirectory);
if (!is_executable($linkOutput)) {
throw new \RuntimeException("PHP {$target} output was not generated: {$output}");
}
}
return $outputs['cli'] ?? $outputs['fpm'];
}
private function absoluteOutputPath(string $output): string
{
if ($output === '') {
throw new \RuntimeException('SAPI output path cannot be empty');
}
if ($output[0] === '/' || preg_match('/^[A-Za-z]:[\\\\\/]/', $output) === 1) {
return $output;
}
$directory = getcwd();
if ($directory === false) {
throw new \RuntimeException('Cannot resolve the SAPI application output directory');
}
return $directory . DIRECTORY_SEPARATOR . $output;
}
private function generateSapiAdapters(string $cliObjects, string $fpmObjects): string
{
$directory = $this->adapterDirectory();
$this->mkdir($directory);
$targets = [];
$rules = [];
if (in_array('cli', $this->targets, true)) {
if ($this->entryFile === null) {
throw new \RuntimeException('The CLI SAPI target requires an entry file');
}
$source = $directory . '/php_cli.c';
$object = $directory . '/php_cli.lo';
$serverSource = $directory . '/php_cli_server.c';
$serverObject = $directory . '/php_cli_server.lo';
$code = $this->readSource('/sapi/cli/php_cli.c');
$needle = "static zend_result cli_seek_file_begin(zend_file_handle *file_handle, char *script_file)\n{\n";
$replacement = $needle
. "\tif (typephp_embedded_file_exists(script_file)) {\n"
. "\t\tzend_stream_init_filename(file_handle, script_file);\n"
. "\t\tfile_handle->primary_script = 1;\n"
. "\t\treturn SUCCESS;\n\t}\n";
$code = $this->patchOnce($code, $needle, $replacement, 'CLI script opener');
$code = $this->patchCliArguments($code);
$code = $this->insertDeclaration($code, true);
$this->writeIfChanged($source, $code);
$serverCode = $this->readSource('/sapi/cli/php_cli_server.c');
$statCall = 'php_sys_stat(buf, &sb)';
if (substr_count($serverCode, $statCall) !== 2) {
throw new \RuntimeException('Cannot apply embedded path checks to the selected PHP CLI Server source');
}
$serverCode = str_replace($statCall, 'typephp_cli_server_stat(buf, &sb)', $serverCode);
$serverCode = $this->insertDeclaration($serverCode, false, true);
$this->writeIfChanged($serverSource, $serverCode);
$targets[] = 'typephp-cli-adapter';
$rules[] = "typephp-cli-adapter: {$object} {$serverObject}\n";
$rules[] = "{$object}: {$source}\n"
. "\t\$(LIBTOOL) --tag=CC --mode=compile \$(CC) -Isapi/cli/ -I\$(top_srcdir)/sapi/cli/ "
. "\$(COMMON_FLAGS) \$(CFLAGS_CLEAN) \$(EXTRA_CFLAGS) -c {$source} -o {$object}\n";
$rules[] = "{$serverObject}: {$serverSource}\n"
. "\t\$(LIBTOOL) --tag=CC --mode=compile \$(CC) -Isapi/cli/ -I\$(top_srcdir)/sapi/cli/ "
. "\$(COMMON_FLAGS) \$(CFLAGS_CLEAN) \$(EXTRA_CFLAGS) -c {$serverSource} -o {$serverObject}\n";
}
if (in_array('fpm', $this->targets, true)) {
if (trim($fpmObjects) === '') {
throw new \RuntimeException('The private PHP build was not configured with FPM support');
}
$source = $directory . '/fpm_main.c';
$object = $directory . '/fpm_main.lo';
$code = $this->readSource('/sapi/fpm/fpm/fpm_main.c');
$needle = '(real_path = tsrm_realpath(script_path_translated, NULL)) == NULL';
$replacement = '(real_path = typephp_embedded_file_exists(script_path_translated) '
. '? estrdup(script_path_translated) : tsrm_realpath(script_path_translated, NULL)) == NULL';
$code = $this->patchOnce($code, $needle, $replacement, 'FPM path check');
$code = $this->insertDeclaration($code);
$this->writeIfChanged($source, $code);
$targets[] = 'typephp-fpm-adapter';
$rules[] = "typephp-fpm-adapter: {$object}\n";
$rules[] = "{$object}: {$source}\n"
. "\t\$(LIBTOOL) --tag=CC --mode=compile \$(CC) -Isapi/fpm/ -I\$(top_srcdir)/sapi/fpm/ "
. '$(COMMON_FLAGS) $(CFLAGS_CLEAN) $(EXTRA_CFLAGS) -I$(top_srcdir)/sapi/fpm '
. '-I$(top_srcdir)/sapi/fpm/fpm '
. "-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1 -c {$source} -o {$object}\n";
}
$path = $directory . '/adapters.mk';
$body = 'include ' . $this->phpBuildDirectory . '/Makefile' . PHP_EOL
. '.PHONY: ' . implode(' ', $targets) . PHP_EOL
. implode(PHP_EOL, $rules);
AtomicFile::write($path, $body);
return $path;
}
private function adapterDirectory(): string
{
$runtime = hash('sha256', $this->phpSourceDirectory . "\0" . $this->phpBuildDirectory);
return $this->buildDirectory . '/cache/sapi/adapters/' . substr($runtime, 0, 16);
}
private function insertDeclaration(
string $code,
bool $cliArguments = false,
bool $cliServerStat = false,
): string {
$needle = '#include "php.h"';
$declarations = 'extern int typephp_embedded_file_exists(const char *path);';
if ($cliArguments) {
$declarations .= PHP_EOL
. 'extern int typephp_cli_prepare_arguments(int *argc, char ***argv, '
. 'const void *options, char ***allocated_argv);' . PHP_EOL
. 'extern void typephp_cli_release_arguments(char **allocated_argv);';
}
if ($cliServerStat) {
$declarations .= PHP_EOL . <<<'C'
extern int typephp_embedded_path_kind(const char *path);
static int typephp_cli_server_stat(const char *path, zend_stat_t *statbuf)
{
int kind = typephp_embedded_path_kind(path);
if (kind == 0) {
return php_sys_stat(path, statbuf);
}
memset(statbuf, 0, sizeof(*statbuf));
statbuf->st_mode = kind == 2 ? (S_IFDIR | 0555) : (S_IFREG | 0444);
statbuf->st_nlink = 1;
return 0;
}
C;
}
$code = preg_replace(
'/' . preg_quote($needle, '/') . '/',
$needle . PHP_EOL . $declarations,
$code,
1,
$replacements,
);
if (!is_string($code) || $replacements !== 1) {
throw new \RuntimeException('Cannot apply PHP header include patch to the selected PHP source');
}
return $code;
}
private function patchCliArguments(string $code): string
{
$setup = "\targv = save_ps_args(argc, argv);\n";
$code = $this->patchOnce(
$code,
$setup,
$setup
. "\tchar **typephp_saved_argv = argv;\n"
. "\tchar **typephp_application_argv = NULL;\n"
. "\tif (typephp_cli_prepare_arguments(&argc, &argv, OPTIONS, &typephp_application_argv) == FAILURE) {\n"
. "\t\tcleanup_ps_args(typephp_saved_argv);\n"
. "\t\treturn FAILURE;\n"
. "\t}\n",
'CLI argument setup',
);
$cleanup = "\tcleanup_ps_args(argv);\n";
return $this->patchOnce(
$code,
$cleanup,
"\ttypephp_cli_release_arguments(typephp_application_argv);\n"
. "\tcleanup_ps_args(typephp_saved_argv);\n",
'CLI argument cleanup',
);
}
private function readSource(string $suffix): string
{
$path = $this->phpSourceDirectory . $suffix;
$code = is_file($path) ? file_get_contents($path) : false;
if (!is_string($code)) {
throw new \RuntimeException("Cannot read PHP SAPI source: {$path}");
}
return $code;
}
private function patchOnce(string $code, string $needle, string $replacement, string $description): string
{
if (substr_count($code, $needle) !== 1) {
throw new \RuntimeException("Cannot apply {$description} patch to the selected PHP source");
}
return str_replace($needle, $replacement, $code);
}
private function replaceObject(string $objects, string $original, string $replacement): string
{
$parts = preg_split('/\s+/', trim($objects)) ?: [];
$index = array_search($original, $parts, true);
if ($index === false) {
throw new \RuntimeException("Cannot locate PHP SAPI object {$original}");
}
$parts[$index] = $replacement;
return implode(' ', $parts);
}
/** @param list<string> $basenames */
private function onlyEntryObjects(string $objects, array $basenames): string
{
$matches = [];
foreach (preg_split('/\s+/', trim($objects)) ?: [] as $object) {
if (in_array(basename($object), $basenames, true)) {
$matches[basename($object)] = $object;
}
}
foreach ($basenames as $basename) {
if (!isset($matches[$basename])) {
throw new \RuntimeException("Cannot locate patched PHP SAPI entry object {$basename}");
}
}
return implode(' ', array_map(
static fn (string $basename): string => $matches[$basename],
$basenames,
));
}
/** @param list<string> $names @return array<string, string> */
private function readMakeVariables(string $makefile, array $names): array
{
$contents = (string) file_get_contents($makefile);
$values = [];
foreach ($names as $name) {
if (preg_match('/^' . preg_quote($name, '/') . '[ \t]*=[ \t]*(.*)$/m', $contents, $match) === 1) {
$values[$name] = trim($match[1]);
}
}
return $values;
}
/** @param list<string> $command */
private function run(array $command, string $directory): void
{
($this->output)('$ ' . implode(' ', array_map('escapeshellarg', $command)));
$process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, $directory);
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, 0777, true) && !is_dir($directory)) {
throw new \RuntimeException("Cannot create directory: {$directory}");
}
}
private function writeIfChanged(string $path, string $contents): void
{
if (is_file($path) && file_get_contents($path) === $contents) {
return;
}
AtomicFile::write($path, $contents);
}
}

@ -0,0 +1,37 @@
<?php
namespace TypePhp\Build;
final class SapiBuildConfiguration
{
/** @return list<string> */
public static function parseTargets(string|array $value): array
{
$values = is_array($value) ? $value : preg_split('/\s*,\s*/', trim($value));
if (!is_array($values)) {
$values = [];
}
$targets = [];
foreach ($values as $target) {
if (!is_string($target) || trim($target) === '') {
throw new \InvalidArgumentException('`sapi` entries must be non-empty strings');
}
$target = strtolower(trim($target));
if ($target === 'both') {
array_push($targets, 'cli', 'fpm');
continue;
}
if (!in_array($target, ['cli', 'fpm'], true)) {
throw new \InvalidArgumentException(
"Invalid SAPI target `{$target}`. Expected cli, fpm, or both.",
);
}
$targets[] = $target;
}
$targets = array_values(array_unique($targets));
if ($targets === []) {
throw new \InvalidArgumentException('`sapi` must select cli, fpm, or both');
}
return $targets;
}
}

@ -0,0 +1,71 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Build;
final class SapiExtensionConfiguration
{
/** Extensions compiled unconditionally by php-src. */
private const array CORE_EXTENSIONS = [
'core', 'date', 'hash', 'json', 'pcre', 'random', 'reflection', 'spl', 'standard',
];
/**
* @param list<string> $extensions
* @return list<string>
*/
public static function configureOptions(string $phpSourceDirectory, array $extensions): array
{
$options = [];
foreach (SapiExtensionRequirements::merge($extensions) as $extension) {
if (in_array($extension, self::CORE_EXTENSIONS, true)) {
continue;
}
if ($extension === 'opcache') {
$options[] = '--enable-opcache';
continue;
}
$directory = $phpSourceDirectory . '/ext/' . str_replace('-', '_', $extension);
if (!is_dir($directory)) {
throw new \RuntimeException("PHP extension `{$extension}` is not bundled with php-src and cannot be linked into the SAPI runtime");
}
$option = self::findConfigureOption($directory, $extension);
// An extension directory without a configure switch is part of the
// core build and needs no extra argument.
if ($option !== null) {
$options[] = $option;
}
}
return array_values(array_unique($options));
}
private static function findConfigureOption(string $directory, string $extension): ?string
{
foreach ([$directory . '/config0.m4', $directory . '/config.m4'] as $file) {
if (!is_file($file)) {
continue;
}
$contents = (string) file_get_contents($file);
if (preg_match_all(
'/PHP_ARG_(WITH|ENABLE)\s*\(\s*\[?([A-Za-z0-9_-]+)\]?/i',
$contents,
$matches,
PREG_SET_ORDER,
) === false) {
continue;
}
foreach ($matches as $match) {
if (SapiExtensionRequirements::normalize($match[2]) !== $extension) {
continue;
}
return ($match[1] === 'WITH' ? '--with-' : '--enable-') . $match[2];
}
}
return null;
}
}

@ -0,0 +1,89 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Build;
final class SapiExtensionRequirements
{
/**
* @param list<string> ...$groups
* @return list<string>
*/
public static function merge(array ...$groups): array
{
$extensions = [];
foreach ($groups as $group) {
foreach ($group as $extension) {
$name = self::normalize($extension);
if ($name !== '') {
$extensions[$name] = true;
}
}
}
$result = array_keys($extensions);
sort($result, SORT_STRING);
return $result;
}
/** @param list<string> $files @return list<string> */
public static function fromEmbeddedVendorFiles(array $files): array
{
$manifests = [];
foreach ($files as $file) {
$vendor = self::vendorRoot($file);
if ($vendor !== null) {
$manifest = dirname($vendor) . DIRECTORY_SEPARATOR . 'composer.json';
if (is_file($manifest)) {
$manifests[$manifest] = true;
}
}
}
$extensions = [];
foreach (array_keys($manifests) as $manifest) {
$decoded = json_decode((string) file_get_contents($manifest), true);
if (!is_array($decoded)) {
throw new \RuntimeException("Invalid Composer manifest: {$manifest}");
}
$require = $decoded['require'] ?? [];
if (!is_array($require)) {
throw new \RuntimeException("Composer `require` must be an object: {$manifest}");
}
foreach (array_keys($require) as $package) {
if (is_string($package) && str_starts_with(strtolower($package), 'ext-')) {
$extensions[] = substr($package, 4);
}
}
}
return self::merge($extensions);
}
public static function normalize(string $extension): string
{
$extension = strtolower(trim($extension));
if (str_starts_with($extension, 'ext-')) {
$extension = substr($extension, 4);
}
$extension = str_replace(' ', '-', $extension);
return match ($extension) {
'zend-opcache', 'opcache' => 'opcache',
default => str_replace('_', '-', $extension),
};
}
private static function vendorRoot(string $file): ?string
{
$directory = is_dir($file) ? $file : dirname($file);
while ($directory !== '' && $directory !== dirname($directory)) {
if (basename($directory) === 'vendor' && is_file($directory . '/autoload.php')) {
return $directory;
}
$directory = dirname($directory);
}
return null;
}
}

@ -0,0 +1,25 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Build;
final readonly class SapiPhpBuild
{
public function __construct(
public string $sourceDirectory,
public string $buildDirectory,
public string $prefix,
public string $phpxArchive,
/** @var array<string, string> */
public array $sapiArchives,
public string $version,
/** @var list<string> */
public array $enabledExtensions = [],
) {
}
}

@ -0,0 +1,368 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Build;
use TypePhp\Installer\PhpBuildConfiguration;
final class SapiPhpBuilder
{
private readonly \Closure $output;
/** @param callable(string):void $output */
public function __construct(
private readonly string $phpxSourceDirectory,
callable $output,
) {
$this->output = \Closure::fromCallable($output);
}
/**
* @param list<string> $targets
* @param list<string> $requiredExtensions
*/
public function prepare(
string $phpVersion,
array $targets,
int $jobs,
array $requiredExtensions = [],
): SapiPhpBuild {
if (PHP_OS_FAMILY !== 'Linux' && PHP_OS_FAMILY !== 'Darwin') {
throw new \RuntimeException('Self-contained SAPI builds currently require Linux or macOS');
}
if ($targets === [] || array_diff($targets, ['cli', 'fpm']) !== []) {
throw new \InvalidArgumentException('SAPI targets must contain cli, fpm, or both');
}
$source = (new OfficialPhpSource(
OfficialPhpSource::defaultCacheDirectory(),
$this->output,
))->prepare($phpVersion);
$sourceVersion = OfficialPhpSource::version($source);
$baseOptions = $this->sourceConfigureOptions($source);
$requiredExtensions = SapiExtensionRequirements::merge($requiredExtensions);
$extensionOptions = SapiExtensionConfiguration::configureOptions($source, $requiredExtensions);
// Build both official executable SAPIs once. Individual projects can
// then select either archive without configuring or compiling php-src.
$runtimeTargets = ['cli', 'fpm'];
$identity = [
$sourceVersion,
$baseOptions,
$runtimeTargets,
PHP_OS_FAMILY,
php_uname('m'),
getenv('CC') ?: '',
getenv('CXX') ?: '',
filemtime($source . '/configure'),
];
$compatibility = hash('sha256', json_encode($identity, JSON_THROW_ON_ERROR));
$cacheDirectory = OfficialPhpSource::defaultCacheDirectory() . '/sapi';
$cached = $this->findCompatibleRuntime(
$cacheDirectory,
$sourceVersion,
$compatibility,
$requiredExtensions,
);
if ($cached !== null) {
($this->output)('Reusing private PHP runtime with extensions: '
. ($requiredExtensions === [] ? 'core' : implode(', ', $requiredExtensions)));
return $cached;
}
// Keep the original no-extension key stable so runtimes created by an
// earlier TypePHP version can be adopted without rebuilding php-src.
$fingerprintInput = $extensionOptions === [] ? $identity : [...$identity, $extensionOptions];
$fingerprint = substr(hash('sha256', json_encode($fingerprintInput, JSON_THROW_ON_ERROR)), 0, 16);
$root = $cacheDirectory . '/php-'
. $sourceVersion . '-' . $fingerprint;
$this->mkdir($root);
$lock = fopen($root . '/build.lock', 'c+');
if ($lock === false || !flock($lock, LOCK_EX)) {
throw new \RuntimeException("Unable to lock SAPI runtime cache: {$root}");
}
try {
$build = $root . '/build';
$prefix = $root . '/install';
$this->mkdir($build);
$this->mkdir($prefix . '/lib/conf.d');
$options = PhpBuildConfiguration::deriveSapi(
[...$baseOptions, ...$extensionOptions],
$prefix,
$runtimeTargets,
);
$configured = $build . '/.typephp-configure.json';
$configuration = json_encode($options, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
if (!is_file($build . '/Makefile') || @file_get_contents($configured) !== $configuration) {
($this->output)('Configuring private PHP ' . $sourceVersion . ' SAPI runtime');
$this->run([$source . '/configure', ...$options], $build);
AtomicFile::write($configured, $configuration);
}
$php = $prefix . '/bin/php';
$fpm = $prefix . '/sbin/php-fpm';
$needsInstall = !is_executable($php) || !is_executable($fpm);
if ($needsInstall) {
($this->output)('Building private PHP runtime (cached across application builds)');
$this->run(['make', '-j' . max(1, $jobs)], $build);
$this->run(['make', 'install'], $build);
}
$sapiArchives = $this->buildSapiArchives($build, $root, $jobs);
$phpxBuild = $root . '/phpx-build';
$phpxArchive = $phpxBuild . '/lib/libphpx.a';
if (!is_file($phpxArchive)) {
($this->output)('Building static PHPX runtime');
$this->run([
'cmake',
'-S', $this->phpxSourceDirectory . '/sapi-static',
'-B', $phpxBuild,
'-DCMAKE_BUILD_TYPE=Release',
'-DPHPX_ROOT=' . $this->phpxSourceDirectory,
'-DPHPX_PHP_PREFIX=' . $prefix,
], $root);
$this->run([
'cmake', '--build', $phpxBuild, '--parallel', (string) max(1, $jobs),
], $root);
}
if (!is_executable($php) || !is_file($phpxArchive)) {
throw new \RuntimeException('Private SAPI runtime build did not produce PHP CLI and libphpx.a');
}
$enabledExtensions = $this->detectEnabledExtensions($php);
$missing = array_values(array_diff($requiredExtensions, $enabledExtensions));
if ($missing !== []) {
throw new \RuntimeException('Private PHP runtime is missing required extensions after build: ' . implode(', ', $missing));
}
AtomicFile::write($root . '/runtime.json', json_encode([
'version' => $sourceVersion,
'compatibility' => $compatibility,
'requested_extensions' => $requiredExtensions,
'enabled_extensions' => $enabledExtensions,
'configure_options' => $options,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL);
return new SapiPhpBuild(
$source,
$build,
$prefix,
$phpxArchive,
$sapiArchives,
$sourceVersion,
$enabledExtensions,
);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** @param list<string> $requiredExtensions */
private function findCompatibleRuntime(
string $cacheDirectory,
string $sourceVersion,
string $compatibility,
array $requiredExtensions,
): ?SapiPhpBuild {
$matches = [];
foreach (glob($cacheDirectory . '/php-' . $sourceVersion . '-*') ?: [] as $root) {
$manifest = $root . '/runtime.json';
if (!is_file($manifest)) {
continue;
}
$metadata = json_decode((string) file_get_contents($manifest), true);
if (!is_array($metadata) || ($metadata['compatibility'] ?? null) !== $compatibility) {
continue;
}
$enabled = SapiExtensionRequirements::merge(
is_array($metadata['enabled_extensions'] ?? null) ? $metadata['enabled_extensions'] : [],
);
if (array_diff($requiredExtensions, $enabled) !== []) {
continue;
}
$runtime = $this->runtimeFromRoot($root, $sourceVersion, $enabled);
if ($runtime !== null) {
$matches[] = [$runtime, count($enabled), filemtime($manifest) ?: 0];
}
}
usort($matches, static fn (array $left, array $right): int => $left[1] <=> $right[1] ?: $right[2] <=> $left[2]);
return $matches[0][0] ?? null;
}
/** @param list<string> $enabledExtensions */
private function runtimeFromRoot(
string $root,
string $version,
array $enabledExtensions,
): ?SapiPhpBuild {
$build = $root . '/build';
$prefix = $root . '/install';
$phpxArchive = $root . '/phpx-build/lib/libphpx.a';
$archives = [
'cli' => $root . '/lib/libphp-cli.a',
'fpm' => $root . '/lib/libphp-fpm.a',
];
if (!is_executable($prefix . '/bin/php')
|| !is_file($phpxArchive)
|| !is_file($archives['cli'])
|| !is_file($archives['fpm'])
|| !is_file($build . '/Makefile')
) {
return null;
}
$source = OfficialPhpSource::defaultCacheDirectory() . '/src/php-' . $version;
return new SapiPhpBuild(
$source,
$build,
$prefix,
$phpxArchive,
$archives,
$version,
$enabledExtensions,
);
}
/** @return list<string> */
private function detectEnabledExtensions(string $php): array
{
$process = proc_open(
[$php, '-n', '-r', 'echo json_encode(get_loaded_extensions());'],
[0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
);
if (!is_resource($process)) {
throw new \RuntimeException("Unable to inspect private PHP extensions: {$php}");
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$status = proc_close($process);
$decoded = is_string($stdout) ? json_decode($stdout, true) : null;
if ($status !== 0 || !is_array($decoded)) {
throw new \RuntimeException("Unable to inspect private PHP extensions: {$php}" . (is_string($stderr) && trim($stderr) !== '' ? PHP_EOL . trim($stderr) : ''));
}
return SapiExtensionRequirements::merge(array_values(array_filter($decoded, 'is_string')));
}
/** @return array<string, string> */
private function buildSapiArchives(string $build, string $root, int $jobs): array
{
$libraryDirectory = $root . '/lib';
$this->mkdir($libraryDirectory);
$archives = [
'cli' => $libraryDirectory . '/libphp-cli.a',
'fpm' => $libraryDirectory . '/libphp-fpm.a',
];
if (is_file($archives['cli']) && is_file($archives['fpm'])) {
return $archives;
}
// The normal PHP build creates every core and SAPI object. Archive
// them once, excluding the two entry objects that TypePHP patches to
// accept embedded primary scripts.
$this->run(['make', '-j' . max(1, $jobs)], $build);
$variables = $this->readMakeVariables($build . '/Makefile', [
'PHP_GLOBAL_OBJS', 'PHP_CLI_OBJS', 'PHP_FPM_OBJS',
]);
foreach ([
'cli' => ['PHP_CLI_OBJS', 'sapi/cli/php_cli.lo'],
'fpm' => ['PHP_FPM_OBJS', 'sapi/fpm/fpm/fpm_main.lo'],
] as $target => [$variable, $entryObject]) {
if (is_file($archives[$target])) {
continue;
}
$objects = $this->objectFiles(
($variables['PHP_GLOBAL_OBJS'] ?? '') . ' ' . ($variables[$variable] ?? ''),
$entryObject,
$build,
);
($this->output)('Caching PHP ' . strtoupper($target) . ' runtime: ' . $archives[$target]);
$temporary = $archives[$target] . '.part-' . bin2hex(random_bytes(6));
$this->run(['ar', 'rcs', $temporary, ...$objects], $build);
if (!rename($temporary, $archives[$target])) {
@unlink($temporary);
throw new \RuntimeException('Unable to store PHP SAPI archive: ' . $archives[$target]);
}
}
return $archives;
}
/** @return list<string> */
private function objectFiles(string $value, string $excluded, string $build): array
{
$objects = [];
foreach (preg_split('/\s+/', trim($value)) ?: [] as $object) {
if ($object === '' || $object === $excluded) {
continue;
}
$object = preg_replace('/\.lo$/', '.o', $object) ?? $object;
$path = str_starts_with($object, '/') ? $object : $build . '/' . $object;
if (!is_file($path)) {
throw new \RuntimeException("PHP build object does not exist: {$path}");
}
$objects[] = $path;
}
if ($objects === []) {
throw new \RuntimeException('PHP build did not publish objects for the requested SAPI archive');
}
return $objects;
}
/** @param list<string> $names @return array<string, string> */
private function readMakeVariables(string $makefile, array $names): array
{
$contents = (string) file_get_contents($makefile);
$values = [];
foreach ($names as $name) {
if (preg_match('/^' . preg_quote($name, '/') . '[ \t]*=[ \t]*(.*)$/m', $contents, $match) === 1) {
$values[$name] = trim($match[1]);
}
}
return $values;
}
/** @return list<string> */
private function sourceConfigureOptions(string $source): array
{
$configNice = $source . '/config.nice';
if (!is_file($configNice)) {
return ['--enable-zts', '--enable-mbstring', '--enable-sockets', '--with-zlib'];
}
$contents = (string) file_get_contents($configNice);
$contents = preg_replace('/^#!.*\n|^#.*\n/m', '', $contents) ?? $contents;
$contents = str_replace(["\\\r\n", "\\\n", '"$@"', "'$@'"], [' ', ' ', '', ''], $contents);
$options = PhpBuildConfiguration::parseShellWords($contents);
return array_values(array_filter($options, static fn (string $value): bool => str_starts_with($value, '--')));
}
/** @param list<string> $command */
private function run(array $command, string $directory): void
{
($this->output)('$ ' . implode(' ', array_map('escapeshellarg', $command)));
$logPath = rtrim($directory, '/\\') . '/.typephp-build.log';
$log = fopen($logPath, 'ab');
if ($log === false) {
throw new \RuntimeException("Unable to open SAPI build log: {$logPath}");
}
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;
fclose($log);
if ($status !== 0) {
$contents = (string) @file_get_contents($logPath);
$lines = preg_split('/\R/', trim($contents)) ?: [];
$tail = implode(PHP_EOL, array_slice($lines, -40));
throw new \RuntimeException('Command failed: ' . implode(' ', $command) . PHP_EOL . 'Build log: ' . $logPath . ($tail === '' ? '' : PHP_EOL . $tail));
}
}
private function mkdir(string $directory): void
{
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new \RuntimeException("Cannot create SAPI build directory: {$directory}");
}
}
}

@ -1,8 +1,8 @@
<?php
/**
* This file is part of TypePHP.
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
@ -40,12 +40,10 @@ trait SourcePipelineTrait
/** @var ?list<string> 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 = '';
private ?string $embeddedArchiveFile = null;
private function getOpcodeBuildPhpCli(): string
@ -164,7 +162,7 @@ trait SourcePipelineTrait
if (file_exists($destination)) {
$base = $this->getBuildDir() . '/cache/legacy/' . basename($legacy);
$destination = $base;
for ($suffix = 1; file_exists($destination); ++$suffix) {
for ($suffix = 1; file_exists($destination); $suffix++) {
$destination = $base . '-' . $suffix;
}
}
@ -280,7 +278,8 @@ trait SourcePipelineTrait
$progress = new Progressbar();
$progress->barStyle([AnsiTerminal::FG_GREEN])
->percentageStyle([AnsiTerminal::TEXT_BOLD])
->labelStyle([AnsiTerminal::FG_CYAN]);
->labelStyle([AnsiTerminal::FG_CYAN])
;
$progress->renderInPlace(0, $total, $label);
return $progress;
}
@ -359,10 +358,10 @@ trait SourcePipelineTrait
$progress = $this->startOpcodeProgress('Opcodes', $batch->pendingCount());
$blobs = $generator->compile(
$batch,
fn(string $file) => $this->climate->warning(
fn (string $file) => $this->climate->warning(
'Skipping non-executable embedded PHP file: ' . $file,
),
fn(int $completed, int $total, string $file) => $this->updateOpcodeProgress(
fn (int $completed, int $total, string $file) => $this->updateOpcodeProgress(
$progress,
'Opcodes',
$completed,
@ -445,7 +444,13 @@ trait SourcePipelineTrait
$this->writeFile(
$output,
(new EmbeddedTableRenderer())->render($embeddedArchive, $phpVersion, $this->isWindows()),
(new EmbeddedTableRenderer())->render(
$embeddedArchive,
$phpVersion,
$this->isWindows(),
!$this->isSapiBuild(),
$this->sapiEntryFile,
),
);
$this->generatedProjectSources[$output] = true;
return $sources;
@ -504,10 +509,16 @@ trait SourcePipelineTrait
private function validateLoadedProjectConfiguration(): void
{
if ($this->embeddedFiles !== [] && !$this->isBuildModeBin()) {
if (in_array('cli', $this->sapiTargets, true) && $this->sapiEntryFile === null) {
$this->error('CLI SAPI builds require an `entry` PHP file');
}
if ($this->sapiEntryFile !== null && !in_array('cli', $this->sapiTargets, true)) {
$this->error('`entry` requires the CLI SAPI target');
}
if ($this->embeddedFiles !== [] && !$this->isBuildModeBin() && !$this->isSapiBuild()) {
$this->error('`embedded-files` requires `mode: bin`');
}
if ($this->embeddedFiles !== []) {
if ($this->embeddedFiles !== [] && !$this->isSapiBuild()) {
$this->getOpcodeBuildExtensionArgs();
}
if ($this->climate->arguments->defined('force')) {
@ -531,7 +542,7 @@ trait SourcePipelineTrait
}
return array_values(array_filter(
$files,
static fn(string $file): bool => realpath($file) !== $generatedStub,
static fn (string $file): bool => realpath($file) !== $generatedStub,
));
}
@ -562,6 +573,12 @@ trait SourcePipelineTrait
return;
}
if ($this->isSapiBuild()) {
$this->prepareSapiBuildEnvironment();
$this->getOpcodeBuildExtensionArgs();
return;
}
$platform = $this->getPlatform();
$phpDir = $this->getPhpDir();
if ($this->isBuildModeEmbed() && $platform instanceof Linux) {
@ -572,7 +589,7 @@ trait SourcePipelineTrait
}
}
if (!($platform instanceof Wasi)) {
if (!$platform instanceof Wasi) {
$this->validatePhpRuntimeMinimum($phpDir);
}
@ -601,7 +618,7 @@ trait SourcePipelineTrait
// All Windows build modes depend on the PHPX import library and runtime.
// Other platforms only run the existing checks in embedded build mode.
if (!$this->isNanoMode()
if (!$this->isNanoMode() && !$this->isSapiBuild()
&& ($this->isBuildModeEmbed() || $this->getPlatform() instanceof Windows)) {
foreach ($this->getPlatform()->getBuildLibraryWarnings(
$this->getPhpDir(),
@ -624,6 +641,90 @@ trait SourcePipelineTrait
}
}
/** @param list<string>|null $sourceDependencies */
private function prepareSapiBuildEnvironment(?array $sourceDependencies = null): void
{
try {
$composerDependencies = SapiExtensionRequirements::fromEmbeddedVendorFiles(
$this->embeddedFiles,
);
$requiredExtensions = SapiExtensionRequirements::merge(
$this->extensionDependencies,
$composerDependencies,
$sourceDependencies ?? [],
);
} catch (\Throwable $exception) {
$this->error('Unable to read SAPI extension requirements: ' . $exception->getMessage());
}
if ($this->sapiPhpBuildDirectory !== null
&& array_diff($requiredExtensions, $this->sapiEnabledExtensions) === []
) {
return;
}
try {
$runtime = (new SapiPhpBuilder(
$this->getPhpxDir(),
fn (string $message) => $this->output($message, 'lightBlue'),
))->prepare(
$this->phpVersion,
$this->sapiTargets,
min(8, max(1, $this->maxJob)),
$requiredExtensions,
);
} catch (\Throwable $exception) {
$this->error('Unable to prepare self-contained PHP SAPI runtime: ' . $exception->getMessage());
}
$this->sapiPhpSourceDirectory = $runtime->sourceDirectory;
$this->sapiPhpBuildDirectory = $runtime->buildDirectory;
$this->sapiPhpPrefix = $runtime->prefix;
$this->sapiPhpxArchive = $runtime->phpxArchive;
$this->sapiRuntimeArchives = $runtime->sapiArchives;
$this->sapiEnabledExtensions = $runtime->enabledExtensions;
putenv('PHP_HOME=' . $runtime->prefix);
$_ENV['PHP_HOME'] = $runtime->prefix;
$this->validatePhpRuntimeMinimum($runtime->prefix);
$this->restartSourceCompilerWithSapiPhp($runtime);
$this->output(
'Using private PHP ' . $runtime->version . ' runtime from ' . $runtime->buildDirectory,
'green',
);
}
private function restartSourceCompilerWithSapiPhp(SapiPhpBuild $runtime): void
{
$targetMinor = implode('.', array_slice(explode('.', $runtime->version), 0, 2));
$compilerMinor = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
if ($targetMinor === $compilerMinor || !$this->compilerRuntime->sourceEntry) {
// The native tpc executable has no host Zend VM. Its opcode worker
// is already the private PHP CLI selected above, so there is no
// compiler PHP process to replace.
return;
}
if (getenv('TYPEPHP_SAPI_PHP_VERSION') === $targetMinor) {
throw new \RuntimeException("Unable to restart tpc with PHP {$targetMinor}; the restarted process still runs PHP {$compilerMinor}");
}
$script = realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? ''));
$php = $runtime->prefix . '/bin/php';
if ($script === false || !is_file($script) || !is_executable($php)) {
throw new \RuntimeException("Cannot restart the source compiler with target PHP {$targetMinor}");
}
global $argv;
$arguments = [$php, $script, ...array_slice($argv, 1)];
$this->output(
"Restarting tpc with PHP {$targetMinor} to match the SAPI runtime and OPcache format",
'lightBlue',
);
putenv('TYPEPHP_SAPI_PHP_VERSION=' . $targetMinor);
$_ENV['TYPEPHP_SAPI_PHP_VERSION'] = $targetMinor;
$process = proc_open($arguments, [STDIN, STDOUT, STDERR], $pipes, getcwd() ?: null);
if (!is_resource($process)) {
throw new \RuntimeException("Unable to start target PHP compiler: {$php}");
}
exit(proc_close($process));
}
/** @param list<string> $files @return list<string> */
private function preprocessProjectFiles(array $files, string $preparedKey): array
{
@ -637,6 +738,7 @@ trait SourcePipelineTrait
$this->prepareFile($file);
} catch (Unsupported $e) {
$this->output(' unsupported syntax: ' . $e->getMessage() . "\n" . ' skip: ' . $file . "\n", 'error');
$this->registerUnsupportedSapiFile($file);
unset($files[$k]);
} catch (SyntaxError $e) {
$this->output(' syntax error: ' . $e->getMessage() . "\n" . ' skip: ' . $file . "\n", 'error');
@ -676,7 +778,7 @@ trait SourcePipelineTrait
$this->error(
"C/C++ compiler executable not found: {$program}\n" .
"Configured compiler command: {$compilerCommand}\n" .
"Install a supported compiler, or select one with --compiler, or set `cpp-compiler` in project.yml."
'Install a supported compiler, or select one with --compiler, or set `cpp-compiler` in project.yml.'
);
}
@ -686,7 +788,7 @@ trait SourcePipelineTrait
$this->error(
"Linker executable not found: {$program}\n" .
"Configured linker command: {$linkerCommand}\n" .
"Install the required linker or update compiler configuration."
'Install the required linker or update compiler configuration.'
);
}
}
@ -773,7 +875,14 @@ trait SourcePipelineTrait
$this->composeTraitDeclarations($files);
$previousPhase = $this->enterCompilerPhase(self::PHASE_CONVERT);
$this->initializeProjectConversion($files);
return $this->finalizeProjectConversion($this->convertProjectFiles($files));
$conversion = $this->convertProjectFiles($files);
if ($this->isSapiBuild()) {
// Function and class ownership is known only after lowering all
// project bodies. Upgrade the cached PHP runtime before code is
// compiled if those bodies introduced another extension.
$this->prepareSapiBuildEnvironment($this->resolveExtensionDependencies());
}
return $this->finalizeProjectConversion($conversion);
} finally {
$this->splitTranslationUnitsEnabled = $previousSplitSetting;
if ($previousPhase !== null) {
@ -816,7 +925,7 @@ trait SourcePipelineTrait
if ($generated === null) {
continue;
}
++$validSourceCount;
$validSourceCount++;
array_push($sourceFiles, ...$generated);
} catch (Unsupported $error) {
$this->reportUnsupportedProjectFile($file, $error);
@ -949,12 +1058,28 @@ trait SourcePipelineTrait
{
echo ' unsupported syntax: ' . $error->getMessage() . "\n";
echo ' skip: ' . $file . "\n";
if (in_array($file, $this->embeddedPhpFiles, true)
if ($this->isSapiBuild()) {
$this->registerUnsupportedSapiFile($file);
} elseif (in_array($file, $this->embeddedPhpFiles, true)
&& !in_array($file, $this->embeddedOpcodeFiles, true)) {
$this->embeddedOpcodeFiles[] = $file;
}
}
private function registerUnsupportedSapiFile(string $file): void
{
if (!$this->isSapiBuild()) {
return;
}
$file = realpath($file) ?: $file;
if (!in_array($file, $this->embeddedPhpFiles, true)) {
$this->embeddedPhpFiles[] = $file;
}
if (!in_array($file, $this->embeddedOpcodeFiles, true)) {
$this->embeddedOpcodeFiles[] = $file;
}
}
/** @return list<string> */
private function finalizeProjectConversion(ProjectConversion $conversion): array
{
@ -962,7 +1087,8 @@ trait SourcePipelineTrait
$this->finalizeIncrementalConversionMetadata($files);
// Trait and interface inputs may emit no standalone translation unit,
// but at least one supported input must participate in the project.
if (!$conversion->hasValidSources()) {
if (!$conversion->hasValidSources()
&& !($this->isSapiBuild() && $this->embeddedOpcodeFiles !== [])) {
$this->stop('No valid source file found');
}
@ -974,8 +1100,11 @@ trait SourcePipelineTrait
$sourceFiles = $conversion->sourceFiles();
array_push($sourceFiles, ...$this->genClassArrayConstantLifecycleSources());
$sourceFiles[] = $this->genExtension();
if ($this->isSapiBuild()) {
$sourceFiles[] = $this->genSapiInternalFunctions();
}
if ($this->isBuildModeEmbed() && !$this->isNanoMode()
&& ($this->embeddedFiles !== [] || $this->embeddedOpcodeFiles !== [])) {
&& ($this->isSapiBuild() || $this->embeddedFiles !== [] || $this->embeddedOpcodeFiles !== [])) {
array_push($sourceFiles, ...$this->genEmbeddedOpcodeTable());
}
if ($this->isNanoMode()) {

@ -1,31 +1,41 @@
<?php
/**
* This file is part of TypePHP.
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp;
use League\CLImate\CLImate;
use PhpParser\Modifiers;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\CallLike;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\FunctionLike;
use PhpParser\NodeAbstract;
use PhpParser\Parser;
use PhpParser\ParserFactory;
use PhpParser\PhpVersion;
use PhpParser\PrettyPrinter;
use TypePhp\Analysis\CompilationStatistics;
use TypePhp\Backend\CompilerBackend;
use TypePhp\Backend\CompilerFactory;
use TypePhp\Build\AstCache;
use TypePhp\Build\CompilerRuntime;
use TypePhp\Build\NativeBuildConfigurationTrait;
use TypePhp\Build\PhpxLocator;
use TypePhp\Build\StableIdRegistry;
use League\CLImate\CLImate;
use TypePhp\Backend\CompilerBackend;
use TypePhp\Backend\CompilerFactory;
use TypePhp\Build\NativeBuildConfigurationTrait;
use TypePhp\Entity\ArgInfo;
use TypePhp\Context\FunctionContext;
use TypePhp\Context\CompilationStateTrait;
use TypePhp\Context\FunctionContext;
use TypePhp\Diagnostics\CliDiagnosticReporter;
use TypePhp\Diagnostics\CompilerDiagnosticTrait;
use TypePhp\Diagnostics\CompileTimeAttributeDiagnostic;
use TypePhp\Diagnostics\CliDiagnosticReporter;
use TypePhp\Diagnostics\DiagnosticReporter;
use TypePhp\Diagnostics\ThrowingDiagnosticReporter;
use TypePhp\Entity\ArgInfo;
use TypePhp\Entity\ClassDef;
use TypePhp\Entity\ConstantDef;
use TypePhp\Entity\FunctionDef;
@ -33,24 +43,26 @@ use TypePhp\Entity\InterfaceDef;
use TypePhp\Entity\MethodDef;
use TypePhp\Entity\PropertyDef;
use TypePhp\Exception\DynamicCall;
use TypePhp\Exception\Redo;
use TypePhp\Exception\Unsupported;
use TypePhp\Generator\AnonClassGenerator;
use TypePhp\Generator\CallArgumentGenerator;
use TypePhp\Generator\ClosureGenerator;
use TypePhp\Generator\FiberGenerator;
use TypePhp\Generator\PlaceHolderGenerator;
use TypePhp\Generator\ParameterCountCheckGenerator;
use TypePhp\Generator\PlaceHolderGenerator;
use TypePhp\Generator\PropertyPromotion;
use TypePhp\Generator\Symbol;
use TypePhp\Generator\Utils;
use TypePhp\Generator\TypeCheckGenerator;
use TypePhp\Generator\Utils;
use TypePhp\Immutable\ImmutableSupportTrait;
use TypePhp\NativeClass\NativeClassSupportTrait;
use TypePhp\NativeClass\NativeGlobalTypeResolver;
use TypePhp\Optimizer\FuncCallOptimizer;
use TypePhp\Optimizer\LoopVarOptimizer;
use TypePhp\Optimizer\SsaPropOptimizer;
use TypePhp\Optimizer\SsaTypeOptimizer;
use TypePhp\Optimizer\LoopVarOptimizer;
use TypePhp\Parser\StdContainerTrait;
use TypePhp\Parser\AssignOpTrait;
use TypePhp\Parser\ArrayExpressionTrait;
use TypePhp\Parser\AssignOpTrait;
use TypePhp\Parser\AstNodeType;
use TypePhp\Parser\BinaryOpTrait;
use TypePhp\Parser\ClassConstantFetchTrait;
@ -64,15 +76,15 @@ use TypePhp\Parser\MethodCallTrait;
use TypePhp\Parser\NullsafeAccessTrait;
use TypePhp\Parser\PropertyAccessTrait;
use TypePhp\Parser\SelectionExpressionTrait;
use TypePhp\Parser\StdContainerTrait;
use TypePhp\Parser\SwitchTrait;
use TypePhp\Parser\TypeConversionTrait;
use TypePhp\Parser\TypeDetectionTrait;
use TypePhp\Parser\UnaryExpressionTrait;
use TypePhp\Parser\UniversalMethodCall;
use TypePhp\Optimizer\FuncCallOptimizer;
use TypePhp\Platform\Linux;
use TypePhp\Platform\Ios;
use TypePhp\Platform\Android;
use TypePhp\Platform\Ios;
use TypePhp\Platform\Linux;
use TypePhp\Platform\Macos;
use TypePhp\Platform\PlatformBase;
use TypePhp\Platform\PlatformFactory;
@ -80,31 +92,16 @@ use TypePhp\Platform\Windows;
use TypePhp\Python\PythonModuleTrait;
use TypePhp\Resolver\DeclarationSymbolTrait;
use TypePhp\Resolver\MagicMethodDetector;
use TypePhp\Resolver\PropertyAccessContext;
use TypePhp\Resolver\NativePropertyAccess;
use TypePhp\Resolver\NameResolutionTrait;
use TypePhp\Resolver\PropertyAccessResult;
use TypePhp\Resolver\NativePropertyAccess;
use TypePhp\Resolver\PropertyAccessContext;
use TypePhp\Resolver\PropertyAccessResolver;
use TypePhp\Resolver\PropertyAccessResult;
use TypePhp\Resolver\Reflection;
use TypePhp\Symbol\SymbolRepository;
use TypePhp\TypeSystem\CompositeTypeCheckerTrait;
use TypePhp\TypeSystem\CompoundTypeDeclarationValidationTrait;
use TypePhp\TypeSystem\NativeTypeCompatibilityTrait;
use TypePhp\NativeClass\NativeClassSupportTrait;
use TypePhp\NativeClass\NativeGlobalTypeResolver;
use TypePhp\Immutable\ImmutableSupportTrait;
use PhpParser\Modifiers;
use PhpParser\Node;
use PhpParser\Node\ArrayItem;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\CallLike;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\FunctionLike;
use PhpParser\NodeAbstract;
use PhpParser\Parser;
use PhpParser\ParserFactory;
use PhpParser\PhpVersion;
use PhpParser\PrettyPrinter;
class CompilerBase implements PropertyAccessContext
{
@ -377,12 +374,12 @@ class CompilerBase implements PropertyAccessContext
public const string BUILD_MODE_BIN = 'bin';
public const string BUILD_MODE_EXT = 'ext';
public const string BUILD_MODE_LIB = 'lib';
public const string BUILD_MODE_SAPI = 'sapi';
public const string ENTRY_FUNCTION = 'main';
protected const string PHASE_IDLE = 'idle';
protected const string PHASE_PREPARE = 'prepare';
protected const string PHASE_COMPOSE = 'compose';
protected const string PHASE_CONVERT = 'convert';
protected string $lang = 'PHP';
protected bool $verbose = false;
protected int $indentLevel = 0;
@ -402,6 +399,7 @@ class CompilerBase implements PropertyAccessContext
* @var array<string, int>
*/
protected array $classMap = [];
/**
* Built-in / compiled-output (module-lifetime) class name → ID. Lazily
* populated after PHP startup and NOT cleared on RSHUTDOWN.
@ -409,6 +407,7 @@ class CompilerBase implements PropertyAccessContext
*/
protected array $persistentClassMap = [];
protected int $persistentClassIndex = 0;
/**
* @var array<string, int>
*/
@ -422,6 +421,7 @@ class CompilerBase implements PropertyAccessContext
* @var array<string, int>
*/
protected array $funcMap = [];
/**
* Built-in / compiled-output (module-lifetime) function/method → ID. Lazily
* populated after PHP startup and NOT cleared on RSHUTDOWN.
@ -430,6 +430,7 @@ class CompilerBase implements PropertyAccessContext
*/
protected array $persistentFuncMap = [];
protected int $persistentFuncIndex = 0;
/**
* Declared-property offset cache for built-in / compiled-output classes.
* Key is `Class::prop`; lazily populated and NOT cleared on RSHUTDOWN.
@ -439,6 +440,7 @@ class CompilerBase implements PropertyAccessContext
*/
protected array $persistentPropMap = [];
protected int $persistentPropIndex = 0;
/**
* Per-generated-access-site Zend object-handler cache slots. Unlike the
* declared-property offset cache above, these are request-local and may
@ -448,11 +450,14 @@ class CompilerBase implements PropertyAccessContext
protected int $methodCallCacheIndex = 0;
protected int $functionCallCacheIndex = 0;
protected int $functionResolutionCacheIndex = 0;
/** @var array<string, int> Per-function ordinal used to name dynamic cache sites. */
protected array $cacheSiteOrdinals = [];
/** Separates declaration-expression sites from executable function bodies. */
protected string $cacheSitePass = 'body';
protected ?StableIdRegistry $stableIdRegistry = null;
/** @var array<string, array<Node\Stmt>> Prepared declaration ASTs keyed by real path. */
protected array $preparedFileAsts = [];
protected bool $traitDeclarationsComposed = false;
@ -502,8 +507,10 @@ class CompilerBase implements PropertyAccessContext
* @var array<string, array<string>>
*/
protected array $symbolCallInFile = [];
/** @var array<string, string> Canonical PHP file => generated declaration header. */
protected array $declarationHeaderFiles = [];
/** @var array<string, true> C++ translation units emitted by this compiler run. */
protected array $generatedProjectSources = [];
protected array $redoAfterDeclare = [];
@ -520,11 +527,14 @@ class CompilerBase implements PropertyAccessContext
protected string $ldflags = '';
protected array $linkLibs = []; // --link-lib / -l: user-specified libraries to link
protected array $linkPaths = []; // --link-path / -L: user-specified library search paths
/** @var list<string> Precompiled project object files linked into the target. */
protected array $projectObjectFiles = [];
/** @var list<string> Required PHP modules recorded in zend_module_entry.deps. */
protected array $extensionDependencies = [];
protected string $extensionVersion = '';
/** @var array<string, string> Custom rows shown in the module's phpinfo section. */
protected array $extensionInfo = [];
protected bool $debug = false;
@ -538,16 +548,38 @@ class CompilerBase implements PropertyAccessContext
protected array $userDefines = []; // --define / -D: user-provided preprocessor macros
protected bool $enableLto = false; // --lto: enable Link Time Optimization (-flto)
protected bool $fullStatic = false; // --full-static: link against the bundled fully-static SDK
/** Generate a VM-less program entry for the Composer php-nano runtime. */
protected bool $nanoMode = false;
/** @var list<string> */
protected array $sapiTargets = [];
/** Absolute path of the embedded primary script used by the CLI SAPI. */
protected ?string $sapiEntryFile = null;
protected ?string $sapiPhpSourceDirectory = null;
protected ?string $sapiPhpBuildDirectory = null;
protected ?string $sapiPhpPrefix = null;
protected ?string $sapiPhpxArchive = null;
/** @var array<string, string> */
protected array $sapiRuntimeArchives = [];
/** @var list<string> */
protected array $sapiEnabledExtensions = [];
/** Enforce the VM-free and external-command restrictions of `--nano`. */
protected bool $nanoPolicyMode = false;
/** @var list<string> Include directories published by Nano Composer packages. */
protected array $nanoRuntimeIncludePaths = [];
/** @var list<string> */
protected array $nanoRuntimeDefines = [];
/** @var array<string, true> Package source files compiled into a Nano executable. */
protected array $nanoRuntimeSources = [];
/** Latest header timestamp used by the shared object-cache path. */
protected int $nanoRuntimeHeaderMtime = 0;
protected string $file;
@ -563,6 +595,7 @@ class CompilerBase implements PropertyAccessContext
protected array $useAliases = [];
protected array $useFunctions = [];
protected array $useConstants = [];
/** @var array<int, array<string, string>> Import aliases separated by class/function/constant domain. */
protected array $useImportAliases = [];
@ -572,28 +605,32 @@ class CompilerBase implements PropertyAccessContext
protected string $class = '';
protected string $parentClass = '';
protected string $interface = '';
/**
* @var array<string, ConstantDef>
*/
protected array $constants = [];
/**
* @var array<string, ClassDef>
*/
protected array $classesDefineInFile = [];
/**
* @var array<string, InterfaceDef>
*/
protected array $interfacesDefineInFile = [];
/**
* @var array<string, FunctionDef>
*/
protected array $functionDefineInFile = [];
protected ?FunctionDef $functionDef = null;
protected ?ClassDef $classDef = null;
protected ?MethodDef $methodDef = null;
protected ?InterfaceDef $interfaceDef = null;
protected bool $inGeneratorBody = false;
/** Parameter-default helpers have no ordinary function-entry declaration block. */
protected bool $allowLocalClassEntryHoisting = true;
private ?DiagnosticReporter $diagnosticReporter = null;
@ -610,22 +647,31 @@ class CompilerBase implements PropertyAccessContext
'GLOBALS' => Type::ARRAY,
];
protected array $globalVars = [];
/** @var array<string, array<string, string>> PHP source => global slot => inferred type. */
protected array $globalVarsInFile = [];
/** @var array<string, string> Global/static storage name => owning PHP file. */
protected array $globalVarDeclInFile = [];
/** @var array<string, string> Global/static Native pointer slot => class name. */
protected array $nativeGlobalObjects = [];
/** Immutable metadata shared by Native global pre-discovery and lowering. */
protected ?NativeGlobalTypeResolver $nativeGlobalTypeResolver = null;
/** @var array<string, string> Lowercase class name => declared Native class name. */
protected array $nativeClassDeclarations = [];
/** @var array<string, true> Request-reset initialization flags for Native static locals. */
protected array $nativeStaticInitializers = [];
/** @var array<string, array<string, true>> PHP source => Native static initialization flags. */
protected array $nativeStaticInitializersInFile = [];
/** @var array<string, string> Native static initialization flag => owning PHP file. */
protected array $nativeStaticInitializerDeclInFile = [];
/** Box inferred integer locals in php::Var to retain Zend integer widening semantics. */
protected bool $varIntTypes = false;
protected bool $decimalTypes = false;
@ -654,7 +700,7 @@ class CompilerBase implements PropertyAccessContext
// Windows platform: store the detected PHP lib file paths.
protected string $windowsPhpEmbedLib = ''; // Path to php8embed.lib
protected string $windowsPhpCoreLib = ''; // Path to php8ts.lib or php8.lib
// New platform and compiler abstraction layers (optional to use).
protected ?PlatformBase $platform = null;
protected ?CompilerBackend $compilerBackend = null;
@ -683,6 +729,7 @@ class CompilerBase implements PropertyAccessContext
/** Whole-program usage collected during the convert phase. */
protected CompilationStatistics $compilationStatistics;
/** @var array<string, true> Fully resolved class names referenced by source code. */
protected array $referencedClasses = [];
@ -922,9 +969,25 @@ class CompilerBase implements PropertyAccessContext
return $this->buildMode === self::BUILD_MODE_LIB;
}
public function isBuildModeSapi(): bool
{
return $this->buildMode === self::BUILD_MODE_SAPI;
}
public function isBuildModeEmbed(): bool
{
return $this->isBuildModeBin() || $this->isBuildModeLib();
return $this->isBuildModeBin() || $this->isBuildModeLib() || $this->isBuildModeSapi();
}
public function isSapiBuild(): bool
{
return $this->sapiTargets !== [];
}
/** @return list<string> */
public function getSapiTargets(): array
{
return $this->sapiTargets;
}
public function getPhpDir(): string
@ -1037,10 +1100,10 @@ class CompilerBase implements PropertyAccessContext
if ($expr->getLine() === $this->debugLine) {
dump($expr);
}
if ($expr instanceof Node\Expr\BinaryOp) {
if ($expr instanceof Expr\BinaryOp) {
$this->assertNativeObjectBinaryOperatorSupported($expr);
}
if ($expr instanceof Expr\Variable) {
if ($expr instanceof Variable) {
$varName = $this->parseIdentifier($expr);
$this->requireVar($expr, $varName);
if ($this->isStdContainer($varName)) {
@ -1265,9 +1328,9 @@ class CompilerBase implements PropertyAccessContext
/**
* Resolve the ClassDef for an object expression (variable or $this).
*/
private function resolveObjectClassDef(Node\Expr $expr): ?ClassDef
private function resolveObjectClassDef(Expr $expr): ?ClassDef
{
if ($expr instanceof Expr\Variable) {
if ($expr instanceof Variable) {
$name = $this->parseIdentifier($expr);
if ($name === 'this_' && $this->classDef) {
return $this->classDef;
@ -1462,7 +1525,7 @@ class CompilerBase implements PropertyAccessContext
*/
protected function materializeRefReturnAsValue(NodeAbstract $value, string $expr): string
{
if ($value instanceof Expr\CallLike && $this->resolveRefReturningCall($value) !== false) {
if ($value instanceof CallLike && $this->resolveRefReturningCall($value) !== false) {
$tmpVar = $this->addTmpVar(Type::VAR);
return '(' . $tmpVar . ' = ' . $expr . ')';
}
@ -1773,14 +1836,7 @@ class CompilerBase implements PropertyAccessContext
$persistentMethod = isset($this->persistentFuncMap[$key]);
$persistentClass = isset($this->persistentClassMap[$class]);
if ($persistentMethod !== $persistentClass) {
throw new \LogicException(sprintf(
'Cache lifetime mismatch for %s: method ID %d is %s, class ID %d is %s',
$key,
$funcId,
$persistentMethod ? 'persistent' : 'request-local',
$classId,
$persistentClass ? 'persistent' : 'request-local',
));
throw new \LogicException(sprintf('Cache lifetime mismatch for %s: method ID %d is %s, class ID %d is %s', $key, $funcId, $persistentMethod ? 'persistent' : 'request-local', $classId, $persistentClass ? 'persistent' : 'request-local'));
}
$helper = $persistentMethod ? 'get_persistent_method' : 'get_method';
$funcIdType = $persistentMethod ? 'PersistentFuncId' : 'RequestFuncId';
@ -1949,9 +2005,9 @@ class CompilerBase implements PropertyAccessContext
}
if ($expr instanceof Node\Scalar\String_) {
$value = trim($expr->value);
return $value !== '' && is_numeric($value) && (float)$value == 0.0;
return $value !== '' && is_numeric($value) && (float) $value == 0.0;
}
if ($expr instanceof Node\Expr\ConstFetch or $expr instanceof Node\Expr\ClassConstFetch) {
if ($expr instanceof Expr\ConstFetch or $expr instanceof Expr\ClassConstFetch) {
return in_array($this->parseExpr($expr), ['0L', '0LL']);
}
return false;
@ -2328,7 +2384,7 @@ class CompilerBase implements PropertyAccessContext
}
if ($expr instanceof Expr\MethodCall && $expr->name instanceof Node\Identifier) {
$class = $this->detectClassOfExpr($expr->var);
if ($class === '' && $expr->var instanceof Expr\Variable && is_string($expr->var->name)) {
if ($class === '' && $expr->var instanceof Variable && is_string($expr->var->name)) {
$var = $this->parseVariable($expr->var);
$class = $var === 'this_' ? $this->getFullClassName() : $this->getDeclaredObjectType($var);
}
@ -2670,7 +2726,7 @@ class CompilerBase implements PropertyAccessContext
if ($function !== false) {
return $this->getFunction($function)->returnsByRef;
}
$reflection = \TypePhp\Resolver\Reflection::getFunction(ltrim($this->getNamespacedFuncName($name), '\\'));
$reflection = Reflection::getFunction(ltrim($this->getNamespacedFuncName($name), '\\'));
return $reflection?->returnsReference();
}
if ($expr instanceof Expr\FuncCall) {
@ -2806,9 +2862,11 @@ class CompilerBase implements PropertyAccessContext
}
if ($this->functionDef->returnType === Type::VOID and !$this->context->inClosure) {
return 'return;';
} elseif ($this->shouldCheckClosureReturnType()) {
}
if ($this->shouldCheckClosureReturnType()) {
return $this->genClosureCheckedReturn(self::VALUE_NULL);
} elseif ($this->functionDef->returnTypeCheck && !$this->context->inClosure) {
}
if ($this->functionDef->returnTypeCheck && !$this->context->inClosure) {
return $this->genUnionCheckedReturn(self::VALUE_NULL);
} else {
return 'return ' . self::VALUE_NULL . ';';
@ -3223,9 +3281,8 @@ class CompilerBase implements PropertyAccessContext
NodeAbstract $expr,
string $class,
string $const,
?string $accessingClass = null
): string|false
{
?string $accessingClass = null,
): string|false {
if (!$this->hasClass($class)) {
return false;
}
@ -3755,12 +3812,12 @@ class CompilerBase implements PropertyAccessContext
$this->addLocalVar($tmpVar, Type::VAR);
$read = $this->emitPropertyHookGetterCall($var, $getter);
if ($isPre) {
$set = $this->emitPropertyHookSetterCall($var, $setter, new Expr\Variable($tmpVar));
$set = $this->emitPropertyHookSetterCall($var, $setter, new Variable($tmpVar));
$this->context->beforeStmtLines[] = "{$tmpVar} = {$read} {$op} 1; {$set};";
} else {
$nextVar = $this->genTmpVarName();
$this->addLocalVar($nextVar, Type::VAR);
$set = $this->emitPropertyHookSetterCall($var, $setter, new Expr\Variable($nextVar));
$set = $this->emitPropertyHookSetterCall($var, $setter, new Variable($nextVar));
$this->context->beforeStmtLines[] = "{$tmpVar} = {$read};";
$this->context->afterStmtLines[] = "{$nextVar} = {$tmpVar} {$op} 1; {$set};";
}
@ -3858,7 +3915,7 @@ class CompilerBase implements PropertyAccessContext
return false;
}
protected function checkInternalFunctionArgCount(string $funcName, Node\Expr\FuncCall $expr): void
protected function checkInternalFunctionArgCount(string $funcName, Expr\FuncCall $expr): void
{
$ref = Reflection::getFunction($funcName);
if ($ref) {
@ -4199,7 +4256,6 @@ class CompilerBase implements PropertyAccessContext
* argument to the callable on the right. Materialising the left operand
* also avoids relying on C++ argument-evaluation order.
*/
protected function parsePostOp(Expr\PostDec|Expr\PostInc $expr, string $op): string
{
if ($this->getTypedArrayAccessDefinition($expr->var) !== null) {
@ -4791,10 +4847,10 @@ class CompilerBase implements PropertyAccessContext
}
if ($scope) {
return "php::include(php::Var($fileName), $type, php::Array{" . implode(', ', $scope) . '})';
return "php::include(php::Var({$fileName}), {$type}, php::Array{" . implode(', ', $scope) . '})';
}
return "php::include(php::Var($fileName), $type)";
return "php::include(php::Var({$fileName}), {$type})";
}
protected function parseScalarFloat(Node\Scalar\Float_ $expr): string
@ -5026,7 +5082,7 @@ class CompilerBase implements PropertyAccessContext
$class = $this->getNativeArrayAccessClass($receiver, $access);
if (!$this->isVarExpr($receiver)) {
$receiverName = $this->materializeNativeObjectReceiver($receiver, $class);
$receiver = new Expr\Variable($receiverName, $receiver->getAttributes());
$receiver = new Variable($receiverName, $receiver->getAttributes());
}
$key = $this->addTmpVar(Type::VAR);
@ -5034,7 +5090,7 @@ class CompilerBase implements PropertyAccessContext
$this->context->beforeStmtLines[] = $key . ' = ' . $keyExpr . ';';
$stableAccess = new Expr\ArrayDimFetch(
$receiver,
new Expr\Variable($key, $access->dim->getAttributes()),
new Variable($key, $access->dim->getAttributes()),
$access->getAttributes(),
);
$exists = $this->parseNativeArrayAccessCall(
@ -5559,7 +5615,7 @@ class CompilerBase implements PropertyAccessContext
if ($this->isVarExpr($left)) {
$varName = '`$' . $this->parseIdentifier($left) . '`';
}
$this->fatalError($left, "Cannot re-assign $varName from `{$fromType}` to `{$toType}`");
$this->fatalError($left, "Cannot re-assign {$varName} from `{$fromType}` to `{$toType}`");
}
protected function checkTypedReferenceAssignExpr(
@ -5602,9 +5658,8 @@ class CompilerBase implements PropertyAccessContext
protected function checkAccessibleByClassName(
string $declaringClass,
int $flags,
?string $accessingClass = null
): bool
{
?string $accessingClass = null,
): bool {
if ($accessingClass !== null) {
$accessingClass = ltrim($accessingClass, '\\');
$scopeClassDef = $this->hasClass($accessingClass)

@ -150,4 +150,54 @@ final class PhpBuildConfiguration
...$result,
];
}
/**
* Build a private CLI/FPM runtime from php-src. Every PHP extension remains
* static so the resulting executables never load host extension binaries.
*
* @param string|list<string> $configureOptions
* @param list<string> $targets
* @return list<string>
*/
public static function deriveSapi(string|array $configureOptions, string $prefix, array $targets): array
{
$replace = [
'--prefix', '--with-config-file-path', '--with-config-file-scan-dir',
'--enable-cli', '--disable-cli', '--enable-fpm', '--disable-fpm',
'--enable-cgi', '--disable-cgi', '--enable-phpdbg', '--disable-phpdbg',
'--enable-embed', '--disable-embed', '--enable-opcache', '--disable-opcache',
'--with-pear', '--without-pear',
];
$drop = ['--with-apxs', '--with-apxs2', '--with-fpm-systemd', ...self::PREFIX_DERIVED];
$result = [];
$options = is_string($configureOptions) ? self::parseShellWords($configureOptions) : $configureOptions;
foreach ($options as $option) {
if (!str_starts_with($option, '--')) {
continue;
}
$name = explode('=', $option, 2)[0];
if (in_array($name, $replace, true) || in_array($name, $drop, true)) {
continue;
}
// A private SAPI cannot depend on extension .so files. Configure's
// --enable-x=shared and --with-x=shared forms both accept the same
// option without the shared value to select static compilation.
$option = preg_replace('/=shared(?:,.*)?$/', '', $option) ?? $option;
$result[] = $option;
}
return array_values(array_unique([
'--prefix=' . $prefix,
'--with-config-file-path=' . $prefix . '/lib',
'--with-config-file-scan-dir=' . $prefix . '/lib/conf.d',
'--enable-cli',
in_array('fpm', $targets, true) ? '--enable-fpm' : '--disable-fpm',
'--disable-cgi',
'--disable-phpdbg',
'--disable-embed',
'--enable-opcache',
'--without-pear',
...$result,
]));
}
}

@ -187,10 +187,15 @@ class Constants
'mode' => [
'longPrefix' => 'mode',
'prefix' => 'm',
'description' => 'Build mode, -m bin(binary), -m lib(shared library), or -m ext(PHP extension), default: bin',
'description' => 'Build mode: bin, lib, ext, or sapi; default: bin',
'required' => false,
'defaultValue' => CompilerBase::BUILD_MODE_BIN,
],
'sapi' => [
'longPrefix' => 'sapi',
'description' => 'Build a self-contained PHP application SAPI: cli, fpm, or both',
'required' => false,
],
'run' => [
'prefix' => 'r',
'longPrefix' => 'run',

@ -71,7 +71,7 @@ final class Android extends UnixPlatform
return [];
}
public function buildPhpIncludePaths(string $phpDir): array
public function buildPhpIncludePaths(string $phpDir, bool $allowTargetVersion = false): array
{
$paths = [
$phpDir . '/include/php',

@ -45,7 +45,7 @@ class Ios extends UnixPlatform
* Its path is resolved from PHPX_HOME by CompilerBase, and php-config is
* never executed for the target.
*/
public function buildPhpIncludePaths(string $phpDir): array
public function buildPhpIncludePaths(string $phpDir, bool $allowTargetVersion = false): array
{
$paths = [
$phpDir . '/include/php',

@ -57,10 +57,10 @@ class Macos extends UnixPlatform
* Add the standard Apple Silicon and Intel Homebrew prefixes after the
* selected PHP installation's include paths.
*/
public function buildPhpIncludePaths(string $phpDir): array
public function buildPhpIncludePaths(string $phpDir, bool $allowTargetVersion = false): array
{
return array_values(array_unique(array_merge(
parent::buildPhpIncludePaths($phpDir),
parent::buildPhpIncludePaths($phpDir, $allowTargetVersion),
self::HOMEBREW_INCLUDE_PATHS,
)));
}

@ -81,7 +81,7 @@ abstract class PlatformBase
/**
* Build the PHP include paths.
*/
abstract public function buildPhpIncludePaths(string $phpDir): array;
abstract public function buildPhpIncludePaths(string $phpDir, bool $allowTargetVersion = false): array;
/**
* Build the PHP library paths.

@ -224,15 +224,17 @@ abstract class UnixPlatform extends PlatformBase
/**
* Build the PHP include paths (obtained dynamically via php-config).
*/
public function buildPhpIncludePaths(string $phpDir): array
public function buildPhpIncludePaths(string $phpDir, bool $allowTargetVersion = false): array
{
$key = $this->getPhpSdkCacheKey($phpDir);
return $this->phpIncludeCache[$key] ??= $this->resolvePhpIncludePaths($phpDir);
$key = $this->getPhpSdkCacheKey($phpDir) . ($allowTargetVersion ? ':target' : ':host');
return $this->phpIncludeCache[$key] ??= $this->resolvePhpIncludePaths($phpDir, $allowTargetVersion);
}
private function resolvePhpIncludePaths(string $phpDir): array
private function resolvePhpIncludePaths(string $phpDir, bool $allowTargetVersion): array
{
$phpConfigPath = $this->findPhpConfig($phpDir);
$phpConfigPath = $allowTargetVersion
? $this->findTargetPhpConfig($phpDir)
: $this->findPhpConfig($phpDir);
if ($phpConfigPath) {
$includes = shell_exec(escapeshellarg($phpConfigPath) . ' --includes 2>/dev/null');
if ($includes) {
@ -267,6 +269,15 @@ abstract class UnixPlatform extends PlatformBase
return $includePaths;
}
private function findTargetPhpConfig(string $phpDir): ?string
{
$candidate = rtrim($phpDir, '/') . '/bin/php-config';
if (!is_executable($candidate)) {
throw new \RuntimeException("Target PHP does not provide an executable bin/php-config: {$phpDir}");
}
return $candidate;
}
/**
* Locate the php-config executable.
*/

@ -238,7 +238,7 @@ class Windows extends PlatformBase
return '/DEBUG';
}
public function buildPhpIncludePaths(string $phpDir): array
public function buildPhpIncludePaths(string $phpDir, bool $allowTargetVersion = false): array
{
return $this->buildPhpSdkIncludePaths($phpDir);
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save