diff --git a/completions/tpc.bash b/completions/tpc.bash index a0bff312..f2758804 100644 --- a/completions/tpc.bash +++ b/completions/tpc.bash @@ -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 diff --git a/phpunit/src/Build/EmbeddedArchiveBuilderTest.php b/phpunit/src/Build/EmbeddedArchiveBuilderTest.php index 6dd350f0..3901d7f0 100644 --- a/phpunit/src/Build/EmbeddedArchiveBuilderTest.php +++ b/phpunit/src/Build/EmbeddedArchiveBuilderTest.php @@ -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 diff --git a/phpunit/src/Build/OfficialPhpSourceTest.php b/phpunit/src/Build/OfficialPhpSourceTest.php new file mode 100644 index 00000000..7c6d3a5c --- /dev/null +++ b/phpunit/src/Build/OfficialPhpSourceTest.php @@ -0,0 +1,55 @@ +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); + } + } +} diff --git a/phpunit/src/Build/SapiApplicationLinkerTest.php b/phpunit/src/Build/SapiApplicationLinkerTest.php new file mode 100644 index 00000000..03895004 --- /dev/null +++ b/phpunit/src/Build/SapiApplicationLinkerTest.php @@ -0,0 +1,105 @@ +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); + } +} diff --git a/phpunit/src/Build/SapiBuildConfigurationTest.php b/phpunit/src/Build/SapiBuildConfigurationTest.php new file mode 100644 index 00000000..58609df2 --- /dev/null +++ b/phpunit/src/Build/SapiBuildConfigurationTest.php @@ -0,0 +1,24 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Expected cli, fpm, or both'); + SapiBuildConfiguration::parseTargets('apache'); + } +} diff --git a/phpunit/src/Build/SapiExtensionRequirementsTest.php b/phpunit/src/Build/SapiExtensionRequirementsTest.php new file mode 100644 index 00000000..f1bcb936 --- /dev/null +++ b/phpunit/src/Build/SapiExtensionRequirementsTest.php @@ -0,0 +1,102 @@ +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', 'directory . '/vendor/package/name/file.php'; + file_put_contents($embedded, '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, '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' +directory); + self::assertSame('example', $index->functionExtension('example_open')); + self::assertSame('example', $index->classExtension('Example\Client')); + } +} diff --git a/phpunit/src/CompilerBaseApiTest.php b/phpunit/src/CompilerBaseApiTest.php index c5ca23a7..8431fc30 100644 --- a/phpunit/src/CompilerBaseApiTest.php +++ b/phpunit/src/CompilerBaseApiTest.php @@ -1,16 +1,26 @@ 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') diff --git a/phpunit/src/Installer/PhpBuildConfigurationTest.php b/phpunit/src/Installer/PhpBuildConfigurationTest.php index 3c0e3be6..59e66bbc 100644 --- a/phpunit/src/Installer/PhpBuildConfigurationTest.php +++ b/phpunit/src/Installer/PhpBuildConfigurationTest.php @@ -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); diff --git a/src/Build/EmbeddedTableRenderer.php b/src/Build/EmbeddedTableRenderer.php index 8b497697..b2c6c98f 100644 --- a/src/Build/EmbeddedTableRenderer.php +++ b/src/Build/EmbeddedTableRenderer.php @@ -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 ' . 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; } diff --git a/src/Build/NativeBuildConfigurationTrait.php b/src/Build/NativeBuildConfigurationTrait.php index 9866a083..185a37a8 100644 --- a/src/Build/NativeBuildConfigurationTrait.php +++ b/src/Build/NativeBuildConfigurationTrait.php @@ -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++'; diff --git a/src/Build/OfficialPhpSource.php b/src/Build/OfficialPhpSource.php new file mode 100644 index 00000000..ec42014c --- /dev/null +++ b/src/Build/OfficialPhpSource.php @@ -0,0 +1,238 @@ +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 $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 $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}"); + } + } +} diff --git a/src/Build/PhpSourceExtensionIndex.php b/src/Build/PhpSourceExtensionIndex.php new file mode 100644 index 00000000..b92e69c7 --- /dev/null +++ b/src/Build/PhpSourceExtensionIndex.php @@ -0,0 +1,92 @@ + */ + private static array $instances = []; + + /** @var array */ + private array $functions = []; + + /** @var array */ + 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 $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); + } +} diff --git a/src/Build/SapiApplicationLinker.php b/src/Build/SapiApplicationLinker.php new file mode 100644 index 00000000..30cea5b5 --- /dev/null +++ b/src/Build/SapiApplicationLinker.php @@ -0,0 +1,358 @@ + */ + private readonly array $sapiArchives, + private readonly string $buildDirectory, + private readonly array $targets, + private readonly ?string $entryFile, + private readonly \Closure $output, + ) { + } + + /** + * @param list $projectObjects + * @param array $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 $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 $names @return array */ + 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 $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); + } +} diff --git a/src/Build/SapiBuildConfiguration.php b/src/Build/SapiBuildConfiguration.php new file mode 100644 index 00000000..1e1a5e3d --- /dev/null +++ b/src/Build/SapiBuildConfiguration.php @@ -0,0 +1,37 @@ + */ + 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; + } +} diff --git a/src/Build/SapiExtensionConfiguration.php b/src/Build/SapiExtensionConfiguration.php new file mode 100644 index 00000000..848fbfbf --- /dev/null +++ b/src/Build/SapiExtensionConfiguration.php @@ -0,0 +1,71 @@ + $extensions + * @return list + */ + 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; + } +} diff --git a/src/Build/SapiExtensionRequirements.php b/src/Build/SapiExtensionRequirements.php new file mode 100644 index 00000000..9ada9329 --- /dev/null +++ b/src/Build/SapiExtensionRequirements.php @@ -0,0 +1,89 @@ + ...$groups + * @return list + */ + 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 $files @return list */ + 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; + } +} diff --git a/src/Build/SapiPhpBuild.php b/src/Build/SapiPhpBuild.php new file mode 100644 index 00000000..bb9e9063 --- /dev/null +++ b/src/Build/SapiPhpBuild.php @@ -0,0 +1,25 @@ + */ + public array $sapiArchives, + public string $version, + /** @var list */ + public array $enabledExtensions = [], + ) { + } +} diff --git a/src/Build/SapiPhpBuilder.php b/src/Build/SapiPhpBuilder.php new file mode 100644 index 00000000..77f3eb9e --- /dev/null +++ b/src/Build/SapiPhpBuilder.php @@ -0,0 +1,368 @@ +output = \Closure::fromCallable($output); + } + + /** + * @param list $targets + * @param list $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 $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 $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 */ + 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 */ + 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 */ + 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 $names @return array */ + 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 */ + 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 $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}"); + } + } +} diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index 69db8d1f..07404c93 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -1,8 +1,8 @@ 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|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 $files @return list */ 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 */ 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()) { diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 9b898b40..bcf0b634 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -1,31 +1,41 @@ */ 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 */ @@ -422,6 +421,7 @@ class CompilerBase implements PropertyAccessContext * @var array */ 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 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> Prepared declaration ASTs keyed by real path. */ protected array $preparedFileAsts = []; protected bool $traitDeclarationsComposed = false; @@ -502,8 +507,10 @@ class CompilerBase implements PropertyAccessContext * @var array> */ protected array $symbolCallInFile = []; + /** @var array Canonical PHP file => generated declaration header. */ protected array $declarationHeaderFiles = []; + /** @var array 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 Precompiled project object files linked into the target. */ protected array $projectObjectFiles = []; + /** @var list Required PHP modules recorded in zend_module_entry.deps. */ protected array $extensionDependencies = []; protected string $extensionVersion = ''; + /** @var array 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 */ + 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 */ + protected array $sapiRuntimeArchives = []; + + /** @var list */ + protected array $sapiEnabledExtensions = []; + /** Enforce the VM-free and external-command restrictions of `--nano`. */ protected bool $nanoPolicyMode = false; + /** @var list Include directories published by Nano Composer packages. */ protected array $nanoRuntimeIncludePaths = []; + /** @var list */ protected array $nanoRuntimeDefines = []; + /** @var array 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> 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 */ protected array $constants = []; + /** * @var array */ protected array $classesDefineInFile = []; + /** * @var array */ protected array $interfacesDefineInFile = []; + /** * @var array */ 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> PHP source => global slot => inferred type. */ protected array $globalVarsInFile = []; + /** @var array Global/static storage name => owning PHP file. */ protected array $globalVarDeclInFile = []; + /** @var array 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 Lowercase class name => declared Native class name. */ protected array $nativeClassDeclarations = []; + /** @var array Request-reset initialization flags for Native static locals. */ protected array $nativeStaticInitializers = []; + /** @var array> PHP source => Native static initialization flags. */ protected array $nativeStaticInitializersInFile = []; + /** @var array 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 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 */ + 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) diff --git a/src/Installer/PhpBuildConfiguration.php b/src/Installer/PhpBuildConfiguration.php index 2bb885b1..9e59ff08 100644 --- a/src/Installer/PhpBuildConfiguration.php +++ b/src/Installer/PhpBuildConfiguration.php @@ -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 $configureOptions + * @param list $targets + * @return list + */ + 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, + ])); + } } diff --git a/src/Metadata/Constants.php b/src/Metadata/Constants.php index 57536d21..45517c33 100644 --- a/src/Metadata/Constants.php +++ b/src/Metadata/Constants.php @@ -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', diff --git a/src/Platform/Android.php b/src/Platform/Android.php index d77957cd..eb04a00e 100644 --- a/src/Platform/Android.php +++ b/src/Platform/Android.php @@ -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', diff --git a/src/Platform/Ios.php b/src/Platform/Ios.php index 09dfca72..80ab5c51 100644 --- a/src/Platform/Ios.php +++ b/src/Platform/Ios.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', diff --git a/src/Platform/Macos.php b/src/Platform/Macos.php index ee7956e7..2f7d59b6 100644 --- a/src/Platform/Macos.php +++ b/src/Platform/Macos.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, ))); } diff --git a/src/Platform/PlatformBase.php b/src/Platform/PlatformBase.php index d1f9c256..423798ec 100644 --- a/src/Platform/PlatformBase.php +++ b/src/Platform/PlatformBase.php @@ -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. diff --git a/src/Platform/UnixPlatform.php b/src/Platform/UnixPlatform.php index e1412071..8fdad6a2 100644 --- a/src/Platform/UnixPlatform.php +++ b/src/Platform/UnixPlatform.php @@ -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. */ diff --git a/src/Platform/Windows.php b/src/Platform/Windows.php index 55589387..74334a53 100644 --- a/src/Platform/Windows.php +++ b/src/Platform/Windows.php @@ -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); } diff --git a/src/Translator.php b/src/Translator.php index 949165bf..f8f63f16 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -1,8 +1,8 @@ Forward declarations for split request-lifecycle helpers. */ private array $classArrayConstantLifecycleDeclarations = []; - private bool $classArrayConstantLifecycleSplit = false; // Windows resource file configuration (icon, version info, etc.) @@ -143,6 +146,7 @@ class Translator extends Preprocessor 'typephp_fiber_generator.h', 'phpx_std.h', ]; + /** * @var array */ @@ -194,7 +198,6 @@ class Translator extends Preprocessor exit(0); } - // Detect the OS, the compiler, and (on Windows) the PHP lib files. $this->detectPlatform(); self::$instance = $this; @@ -287,7 +290,7 @@ class Translator extends Preprocessor $this->platform = new Windows( phpLibs: [$this->windowsPhpCoreLib, $this->windowsPhpEmbedLib], isZts: $this->isPhpZts, - phpSdkPath: $this->getPhpDir() . '\\SDK' + phpSdkPath: $this->getPhpDir() . '\SDK' ); } @@ -337,7 +340,7 @@ class Translator extends Preprocessor if (!CompilerFactory::supportsTarget($explicit, $target)) { $this->error( "--full-static requires a clang that can target {$target}, but {$explicit} cannot.\n" - . " Pass a native clang, for example: --compiler=/usr/bin/clang" + . ' Pass a native clang, for example: --compiler=/usr/bin/clang' ); } return $explicit; @@ -351,7 +354,7 @@ class Translator extends Preprocessor $this->error( "--full-static requires a clang that can target {$target}; none was found.\n" - . " Install clang, or pass it explicitly, for example: --compiler=/usr/bin/clang" + . ' Install clang, or pass it explicitly, for example: --compiler=/usr/bin/clang' ); } @@ -427,6 +430,7 @@ class Translator extends Preprocessor ['-o, --output ', 'Output name or path (default: input basename)'], ['-f, --force', 'Clear incremental caches and force a full rebuild'], ['-m, --mode ', 'Build mode: bin, lib, or ext (default: bin)'], + ['--sapi ', 'Build a self-contained PHP CLI, FPM, or both'], ['-r, --run', 'Run the compiled binary after a successful build'], ['-j, --job ', 'Number of parallel compilation jobs (default: 4)'], ['--cxx-std ', 'C++ standard version (default: c++17)'], @@ -489,6 +493,9 @@ class Translator extends Preprocessor if ($this->climate->arguments->defined('mode')) { $this->setBuildMode($this->climate->arguments->get('mode')); } + if ($this->climate->arguments->defined('sapi')) { + $this->configureSapiTargets((string) $this->climate->arguments->get('sapi')); + } // --full-static links the whole musl C runtime into the artifact, so it // only makes sense for a self-contained executable. A shared object @@ -874,14 +881,18 @@ class Translator extends Preprocessor 'binary', 'cli' => self::BUILD_MODE_BIN, 'extension' => self::BUILD_MODE_EXT, 'library', 'shared', 'dll', 'dylib', 'so' => self::BUILD_MODE_LIB, + 'sapi' => self::BUILD_MODE_SAPI, default => $mode, }; - if (!in_array($mode, [self::BUILD_MODE_BIN, self::BUILD_MODE_EXT, self::BUILD_MODE_LIB], true)) { - $this->error("Invalid build mode `{$mode}`. Expected bin, lib, or ext."); + if (!in_array($mode, [self::BUILD_MODE_BIN, self::BUILD_MODE_EXT, self::BUILD_MODE_LIB, self::BUILD_MODE_SAPI], true)) { + $this->error("Invalid build mode `{$mode}`. Expected bin, lib, ext, or sapi."); } $this->buildMode = $mode; + if ($mode === self::BUILD_MODE_SAPI && $this->sapiTargets === []) { + $this->sapiTargets = ['cli']; + } } public function setTargetName(string $name): void @@ -948,6 +959,21 @@ class Translator extends Preprocessor return $targetFile; } + /** @return array */ + public function getSapiOutputFiles(): array + { + $base = $this->explicitOutputBasename ?? $this->targetName; + $extension = $this->explicitOutputExtension ?? ''; + $directory = $this->outputDir === '' ? '' : rtrim($this->outputDir, '/\\') . '/'; + $multipleTargets = count($this->sapiTargets) > 1; + $outputs = []; + foreach ($this->sapiTargets as $target) { + $suffix = $multipleTargets ? '-' . $target : ''; + $outputs[$target] = $directory . $base . $suffix . $extension; + } + return $outputs; + } + public function getLibraryImportStubFile(): string { $directory = $this->outputDir !== '' ? $this->outputDir : (getcwd() ?: $this->rootPath); @@ -997,8 +1023,7 @@ class Translator extends Preprocessor ?string $sourceFile = null, bool $commonOnly = false, bool $includeGlobals = true, - ): string - { + ): string { $includeCommon = $sourceFile === null; $projectNamespace = $this->getProjectNamespace(); $lines[] = '#include '; @@ -1125,9 +1150,7 @@ class Translator extends Preprocessor $entry = $this->getFunction(self::ENTRY_FUNCTION); if (count($entry->argInfoList) !== 0 && count($entry->argInfoList) !== 2) { - throw new \RuntimeException( - 'Nano main() accepts either no parameters or (int $argc, array $argv)' - ); + throw new \RuntimeException('Nano main() accepts either no parameters or (int $argc, array $argv)'); } if (!in_array($entry->returnType, [Type::INT, Type::VOID], true)) { throw new \RuntimeException('Nano main() must return int or void'); @@ -1151,6 +1174,48 @@ class Translator extends Preprocessor return $file; } + public function genSapiInternalFunctions(): string + { + if (!$this->isSapiBuild() || $this->sapiPhpBuildDirectory === null) { + throw new \LogicException('SAPI internal module generation requires a prepared PHP build'); + } + $source = $this->sapiPhpBuildDirectory . '/main/internal_functions_cli.c'; + $code = is_file($source) ? file_get_contents($source) : false; + if (!is_string($code)) { + throw new \RuntimeException("Private PHP build did not generate {$source}"); + } + + $table = 'static zend_module_entry * const php_builtin_extensions[] = {'; + if (substr_count($code, $table) !== 1) { + throw new \RuntimeException('Cannot locate PHP built-in extension table in ' . $source); + } + $modulePointer = $this->getSapiModulePointerName(); + $code = str_replace( + $table, + 'extern zend_module_entry *' . $modulePointer . ';' . PHP_EOL . PHP_EOL . $table, + $code, + ); + $registration = 'return php_register_extensions(php_builtin_extensions, EXTCOUNT);'; + $replacement = 'if (php_register_extensions(php_builtin_extensions, EXTCOUNT) != SUCCESS) {' . PHP_EOL + . "\t\treturn FAILURE;" . PHP_EOL + . "\t}" . PHP_EOL + . "\treturn zend_register_internal_module({$modulePointer}) == NULL ? FAILURE : SUCCESS;"; + if (substr_count($code, $registration) !== 1) { + throw new \RuntimeException('Cannot adapt PHP internal extension registration in ' . $source); + } + $code = str_replace($registration, $replacement, $code); + + $output = $this->getBuildDir() . '/sapi-internal-functions-' . $this->targetName . '.c'; + $this->writeFile($output, $code); + $this->generatedProjectSources[$output] = true; + return $output; + } + + private function getSapiModulePointerName(): string + { + return 'typephp_sapi_module_' . $this->getModuleName(); + } + /** @return array{declarations: string, registration: string} */ private function genTraitMetadataCode(): array { @@ -1237,7 +1302,9 @@ class Translator extends Preprocessor // Keep out of the shared PCH dependency set used by every source. $code .= '#include ' . PHP_EOL; - if ($this->isBuildModeEmbed() && !$this->isNanoMode()) { + if ($this->isSapiBuild()) { + $code .= '#include ' . PHP_EOL; + } elseif ($this->isBuildModeEmbed() && !$this->isNanoMode()) { $code .= '#include ' . PHP_EOL; if ($this->embeddedFiles === [] && $this->embeddedOpcodeFiles === []) { // The runtime still calls these hooks; keep empty builds in this translation unit. @@ -1537,6 +1604,12 @@ CODE; if (!$this->isWasiTarget() && !$this->isNanoMode()) { $code .= 'typephp_register_fiber_generator_class();' . PHP_EOL; } + if ($this->isSapiBuild()) { + // Install the process-wide compile hook only after every fallible + // MINIT step has succeeded, so a failed module startup cannot + // leave zend_compile_file pointing at TypePHP code. + $code .= 'typephp_opcode_table_startup();' . PHP_EOL; + } // The register_class_*() calls below install the persistent AST // constants. Their release runs in MSHUTDOWN, and Zend only calls @@ -1550,13 +1623,7 @@ CODE; $registrationCode .= $registerSymbolFn . '(module_number);' . PHP_EOL; } if ($releaseAstConstantFns !== [] && str_contains($registrationCode, 'return FAILURE')) { - throw new \LogicException( - 'MINIT must not fail after class registration begins: a FAILURE return would' - . ' leave persistent enum-case AST constants in the class table with no' - . ' MSHUTDOWN guaranteed to release them before destroy_zend_class().' - . ' Move the fallible step before the first register_class_*() call, or' - . ' release the AST constants on its failure path.' - ); + throw new \LogicException('MINIT must not fail after class registration begins: a FAILURE return would leave persistent enum-case AST constants in the class table with no MSHUTDOWN guaranteed to release them before destroy_zend_class(). Move the fallible step before the first register_class_*() call, or release the AST constants on its failure path.'); } $code .= $registrationCode; $code .= 'return SUCCESS;' . PHP_EOL; @@ -1588,6 +1655,9 @@ CODE; $code .= 'typephp_unregister_trait_metadata(module_number);' . PHP_EOL; } $code .= 'typephp_uninstall_reflection_attribute_handlers();' . PHP_EOL; + if ($this->isSapiBuild()) { + $code .= 'typephp_opcode_table_shutdown();' . PHP_EOL; + } $code .= 'return SUCCESS;' . PHP_EOL; $code .= '}' . PHP_EOL . PHP_EOL; @@ -1765,6 +1835,10 @@ CODE; } } + if ($this->isSapiBuild()) { + // The request wrapper has no fallible initialization after it. + $code .= 'typephp_opcode_table_request_startup();' . PHP_EOL; + } $code .= 'return SUCCESS;' . PHP_EOL; $code .= '}' . PHP_EOL . PHP_EOL; // rinit end @@ -1775,6 +1849,11 @@ PHP_RSHUTDOWN_FUNCTION({$moduleName}) { delete php_request_cache; php_request_cache = nullptr; module_clean(); +CODE; + if ($this->isSapiBuild()) { + $code .= ' typephp_opcode_table_request_shutdown();' . PHP_EOL; + } + $code .= <<<'CODE' return SUCCESS; } CODE; @@ -1831,6 +1910,10 @@ CODE; if ($this->isBuildModeExt()) { $code .= "ZEND_GET_MODULE({$moduleName});\n"; $code .= '} // namespace ' . $projectNamespace . PHP_EOL; + } elseif ($this->isBuildModeSapi()) { + $code .= '} // namespace ' . $projectNamespace . PHP_EOL . PHP_EOL; + $code .= 'extern "C" zend_module_entry *' . $this->getSapiModulePointerName() + . ' = &' . $projectNamespace . '::' . $moduleName . '_module_entry;' . PHP_EOL; } elseif ($this->isBuildModeEmbed() && !$this->isNanoMode()) { $code .= '} // namespace ' . $projectNamespace . PHP_EOL . PHP_EOL; $code .= 'TYPEPHP_EMBED_GET_MODULE_FUNCTION(' . $this->targetName . ') {' . PHP_EOL; @@ -1861,19 +1944,22 @@ CODE; { $dependencies = $this->extensionDependencies; $seen = []; + $sourceIndex = $this->isSapiBuild() && $this->sapiPhpSourceDirectory !== null + ? PhpSourceExtensionIndex::forSource($this->sapiPhpSourceDirectory) + : null; foreach ($dependencies as $dependency) { $seen[strtolower($dependency)] = true; } foreach (array_keys($this->compilationStatistics->get(CompilationStatistics::FUNCTIONS)) as $function) { $reflection = Reflection::getFunction(ltrim($function, '\\')); - if ($reflection === null || !$reflection->isInternal()) { - continue; - } + $extension = $reflection !== null && $reflection->isInternal() + ? $reflection->getExtensionName() + : $sourceIndex?->functionExtension($function); $this->appendExtensionDependency( $dependencies, $seen, - $reflection->getExtensionName(), + $extension, ); } @@ -1884,13 +1970,13 @@ CODE; ksort($classes, SORT_STRING); foreach (array_keys($classes) as $class) { $reflection = Reflection::getClass(ltrim($class, '\\')); - if ($reflection === null || !$reflection->isInternal()) { - continue; - } + $extension = $reflection !== null && $reflection->isInternal() + ? $reflection->getExtensionName() + : $sourceIndex?->classExtension($class); $this->appendExtensionDependency( $dependencies, $seen, - $reflection->getExtensionName(), + $extension, ); } @@ -2020,8 +2106,8 @@ CODE; } return hash_final($context); } - private bool $memoizeGeneratedCompileInputs = false; + /** @var array */ private array $generatedCompileInputCache = []; @@ -2193,13 +2279,13 @@ CODE; } /** - * @return null|array{ + * @return array{ * language: ?string, * options: CompileOptions, * cacheable_misc: bool, * nano_runtime: bool, * generated_project: bool - * } + * }|null */ private function prepareCompileFileTask( string $cppFile, @@ -2324,16 +2410,23 @@ CODE; private function getEmbeddedRuntimeSources(): array { $sources = []; - $runtimeSource = $this->getPhpxDir() . '/src/misc/typephp_runtime.cc'; - // PHPX 2.6.3 keeps the common runtime in typephp_main.cc. Newer PHPX - // versions split it out so the object can be shared across projects. - if (is_file($runtimeSource)) { - $sources[] = $runtimeSource; - } - $sources[] = $this->getPhpxDir() . '/src/misc/typephp_main.cc'; - if ($this->embeddedFiles !== [] || $this->embeddedOpcodeFiles !== []) { + if (!$this->isSapiBuild()) { + $runtimeSource = $this->getPhpxDir() . '/src/misc/typephp_runtime.cc'; + // PHPX 2.6.3 keeps the common runtime in typephp_main.cc. Newer PHPX + // versions split it out so the object can be shared across projects. + if (is_file($runtimeSource)) { + $sources[] = $runtimeSource; + } + $sources[] = $this->getPhpxDir() . '/src/misc/typephp_main.cc'; + } + if ($this->isSapiBuild() || $this->embeddedFiles !== [] || $this->embeddedOpcodeFiles !== []) { $sources[] = $this->getPhpxDir() . '/src/misc/typephp_opcode_table.cc'; - $sources[] = $this->getEmbeddedOpcodeDecoderSource(); + if ($this->isSapiBuild()) { + $sources[] = $this->getPhpxDir() . '/src/misc/typephp_sapi.cc'; + } + if ($this->embeddedOpcodeFiles !== []) { + $sources[] = $this->getEmbeddedOpcodeDecoderSource(); + } } return $sources; } @@ -2344,9 +2437,7 @@ CODE; // Distribution PHP headers need not live under the PHP prefix. $this->getOpcodeBuildExtensionArgs(); if (!preg_match('/^8\.(4|5)\./', $this->opcodeBuildPhpVersion, $versionMatch)) { - throw new \RuntimeException( - "Unsupported opcode decoder PHP version: {$this->opcodeBuildPhpVersion}", - ); + throw new \RuntimeException("Unsupported opcode decoder PHP version: {$this->opcodeBuildPhpVersion}"); } return $this->getPhpxDir() . '/thirdparty/opcache/opcode_unserialize_8' . $versionMatch[1] . '.c'; @@ -2540,7 +2631,8 @@ CODE; $progress = new Progressbar(); $progress->barStyle([AnsiTerminal::FG_GREEN]) ->percentageStyle([AnsiTerminal::TEXT_BOLD]) - ->labelStyle([AnsiTerminal::FG_CYAN]); + ->labelStyle([AnsiTerminal::FG_CYAN]) + ; $progress->renderInPlace(0, $totalFiles, 'Compiling'); } @@ -2695,6 +2787,30 @@ CODE; public function build(array $objectFiles): string { + if ($this->isSapiBuild()) { + if ($this->sapiPhpSourceDirectory === null + || $this->sapiPhpBuildDirectory === null + || $this->sapiPhpxArchive === null + || $this->sapiRuntimeArchives === []) { + throw new \LogicException('SAPI runtime was not prepared before linking'); + } + try { + $target = (new SapiApplicationLinker( + $this->sapiPhpSourceDirectory, + $this->sapiPhpBuildDirectory, + $this->sapiPhpxArchive, + $this->sapiRuntimeArchives, + $this->getBuildDir(), + $this->sapiTargets, + $this->sapiEntryFile, + fn (string $message) => $this->output($message, 'lightBlue'), + ))->link($objectFiles, $this->getSapiOutputFiles()); + } catch (\Throwable $exception) { + $this->error('SAPI link failed: ' . $exception->getMessage()); + } + $this->climate->green('Build successful: ' . $target); + return $target; + } $targetFile = $this->getTargetFileName(); // Windows: add the .res resource file to the link @@ -2782,10 +2898,7 @@ CODE; fclose($pipes[2]); $status = proc_close($process); if ($status !== 0) { - throw new \RuntimeException( - "Nano audit command failed with status {$status}" - . ($stderr === '' ? '' : ": {$stderr}"), - ); + throw new \RuntimeException("Nano audit command failed with status {$status}" . ($stderr === '' ? '' : ": {$stderr}")); } return (string) $stdout; } @@ -2807,7 +2920,11 @@ CODE; public function run(string $targetFile): never { - if ($this->buildMode !== self::BUILD_MODE_BIN) { + if ($this->isSapiBuild() && !in_array('cli', $this->sapiTargets, true)) { + $this->climate->error('--run requires the CLI target in SAPI mode'); + exit(1); + } + if ($this->buildMode !== self::BUILD_MODE_BIN && !$this->isSapiBuild()) { $this->climate->error('--run is only supported in binary mode (-m bin), not library or extension mode'); exit(1); } @@ -3127,8 +3244,7 @@ CODE; string $asyncExportsFile, string $package, string $world, - ): void - { + ): void { if (!$this->isWasiTarget() || !$this->isBuildModeLib()) { $this->error('WIT interfaces can only be generated for a WASI library build'); } @@ -3203,10 +3319,7 @@ CODE; { $globalHeaders = $this->getGeneratedSourceGlobalHeaders(); if ($this->declarationHeaderFiles === []) { - $declarationHeaders = [ - "php_{$this->targetName}_func_decl.h", - "php_{$this->targetName}_data_decl.h", - ]; + $declarationHeaders = [$this->getRuntimeDeclarationHeaderName()]; } elseif ($allDeclarations) { $declarationHeaders = [ $this->getRuntimeDeclarationHeaderName(), @@ -3256,10 +3369,7 @@ CODE; private function genExtensionIncludeHeaderFiles(): string { if ($this->declarationHeaderFiles === []) { - $declarationHeaders = [ - "php_{$this->targetName}_func_decl.h", - "php_{$this->targetName}_data_decl.h", - ]; + $declarationHeaders = [$this->getRuntimeDeclarationHeaderName()]; } else { $declarationHeaders = [$this->getRuntimeDeclarationHeaderName()]; // The callbacks emitted here bridge stub declarations to native @@ -3780,7 +3890,7 @@ CODE; { $header = $this->declarationHeaderFiles[$sourceFile] ?? basename($this->getDeclarationHeaderFile($sourceFile)); - $stem = preg_replace('/_decl\\.h$/', '', basename($header)) ?: pathinfo($header, PATHINFO_FILENAME); + $stem = preg_replace('/_decl\.h$/', '', basename($header)) ?: pathinfo($header, PATHINFO_FILENAME); return $this->getBuildDir() . '/init/' . $stem . '_class_constants.cc'; } @@ -3836,8 +3946,8 @@ CODE; if (!$classDef instanceof ClassDef || !$classDef->nativeObject) { $classNameStr = $this->genCharPtr($classDef->getNamespacedName(false), true); $classConstStr = $this->genCharPtr($constant->name); - $init .= "php::updateConstant($classNameStr, $classConstStr, {$constant->value});\n"; - $clean .= "php::updateConstant($classNameStr, $classConstStr, php::null);\n"; + $init .= "php::updateConstant({$classNameStr}, {$classConstStr}, {$constant->value});\n"; + $clean .= "php::updateConstant({$classNameStr}, {$classConstStr}, php::null);\n"; } $init .= "} while(0);\n"; $operations[] = [ @@ -3877,8 +3987,8 @@ CODE; 'key' => 'inherited:' . $classDef->getNamespacedName(false) . ':' . $constant->name . ':' . $parentDef->getNamespacedName(false), 'dependencies' => [$parentDef->sourceFile], - 'init' => "php::updateConstant($classNameStr, $classConstStr, {$constName});\n", - 'clean' => "php::updateConstant($classNameStr, $classConstStr, php::null);\n", + 'init' => "php::updateConstant({$classNameStr}, {$classConstStr}, {$constName});\n", + 'clean' => "php::updateConstant({$classNameStr}, {$classConstStr}, php::null);\n", ]; } } @@ -3901,8 +4011,8 @@ CODE; 'key' => 'interface:' . $classDef->getNamespacedName(false) . ':' . $constant->name . ':' . $interfaceDef->getNamespacedName(false), 'dependencies' => [$interfaceDef->sourceFile], - 'init' => "php::updateConstant($classNameStr, $classConstStr, {$constName});\n", - 'clean' => "php::updateConstant($classNameStr, $classConstStr, php::null);\n", + 'init' => "php::updateConstant({$classNameStr}, {$classConstStr}, {$constName});\n", + 'clean' => "php::updateConstant({$classNameStr}, {$classConstStr}, php::null);\n", ]; } } @@ -3988,7 +4098,7 @@ CODE; && ( $path[0] === '/' || $path[0] === '\\' - || preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1 + || preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1 ); } @@ -4048,6 +4158,23 @@ CODE; if (array_key_exists('php-version', $cfg) && !$this->climate->arguments->defined('php-version')) { $this->setPhpVersion((string) $cfg['php-version']); } + if (array_key_exists('sapi', $cfg) && !$this->climate->arguments->defined('sapi')) { + $this->configureSapiTargets($cfg['sapi']); + } + if (array_key_exists('entry', $cfg)) { + if (!is_string($cfg['entry']) || trim($cfg['entry']) === '') { + $this->error('`entry` must be a non-empty PHP file path'); + } + $entryPath = $this->resolvePath($cfg['entry'], $projectDir, 'Entry path'); + $entry = realpath($entryPath); + if ($entry === false || !is_file($entry)) { + $this->error('Entry file does not exist: `' . $cfg['entry'] . '`'); + } + if (!FileScanner::isPhpFile($entry) || str_ends_with($entry, '.stub.php')) { + $this->error('`entry` must select an executable PHP file'); + } + $this->sapiEntryFile = $entry; + } if (!empty($cfg['sources'])) { $sources = $cfg['sources']; @@ -4111,22 +4238,25 @@ CODE; } } } - $this->embeddedFiles = array_values(array_unique($this->embeddedFiles)); - sort($this->embeddedFiles, SORT_STRING); - $this->embeddedPhpFiles = array_values(array_filter( - $this->embeddedFiles, - // PHP extension stubs are API declarations for code generators. - // They remain in the raw archive but must never be executed. - static fn(string $file): bool => FileScanner::isPhpFile($file) - && !str_ends_with($file, '.stub.php'), - )); - if ($this->embeddedFiles !== []) { - $this->output( - 'embedded-files: found ' . count($this->embeddedFiles) - . ' files (' . count($this->embeddedPhpFiles) . ' PHP)', - 'lightBlue', - ); - } + } + if ($this->sapiEntryFile !== null) { + $this->embeddedFiles[] = $this->sapiEntryFile; + } + $this->embeddedFiles = array_values(array_unique($this->embeddedFiles)); + sort($this->embeddedFiles, SORT_STRING); + $this->embeddedPhpFiles = array_values(array_filter( + $this->embeddedFiles, + // PHP extension stubs are API declarations for code generators. + // They remain in the raw archive but must never be executed. + static fn (string $file): bool => FileScanner::isPhpFile($file) + && !str_ends_with($file, '.stub.php'), + )); + if ($this->embeddedFiles !== []) { + $this->output( + 'embedded-files: found ' . count($this->embeddedFiles) + . ' files (' . count($this->embeddedPhpFiles) . ' PHP)', + 'lightBlue', + ); } if (array_key_exists('optimize', $cfg)) { @@ -4273,7 +4403,7 @@ CODE; $linkLibs = $cfg['link-libs'] ?? null; if (!empty($linkLibs) && is_array($linkLibs)) { foreach ($linkLibs as $lib) { - $this->linkLibs[] = (string)$lib; + $this->linkLibs[] = (string) $lib; } } @@ -4330,7 +4460,11 @@ CODE; // Read mode/type/build-mode (supports both the CLI and YAML naming) $buildMode = $cfg['mode'] ?? $cfg['build-mode'] ?? $cfg['type'] ?? null; if (!empty($buildMode)) { - $this->setBuildMode((string) $buildMode); + $normalizedMode = strtolower(trim((string) $buildMode)); + if ($this->isSapiBuild() && $normalizedMode !== self::BUILD_MODE_SAPI) { + $this->error('`sapi` is an independent build mode and cannot be combined with `mode`'); + } + $this->setBuildMode($normalizedMode); } // Read ignore (supports both hyphen and underscore) @@ -4371,7 +4505,7 @@ CODE; // Verify that the icon file exists if (!empty($resource['icon'])) { $iconPath = $resource['icon']; - if (!preg_match('/^[A-Za-z]:\\|^\//', $iconPath)) { + if (!preg_match('/^[A-Za-z]:\|^\//', $iconPath)) { $iconPath = $projectDir . DIRECTORY_SEPARATOR . $iconPath; } if (!file_exists($iconPath)) { @@ -4389,7 +4523,7 @@ CODE; $this->error('`manifest` must be a string (path to manifest file)'); } $manifestPath = $manifest; - if (!preg_match('/^[A-Za-z]:\\|^\//', $manifestPath)) { + if (!preg_match('/^[A-Za-z]:\|^\//', $manifestPath)) { $manifestPath = $projectDir . DIRECTORY_SEPARATOR . $manifestPath; } if (!file_exists($manifestPath)) { @@ -4401,7 +4535,23 @@ CODE; $this->resourceConfig['manifest'] = $manifest; } - return $this->filterIgnoredFiles($list); + $list = $this->filterIgnoredFiles($list); + if ($this->sapiEntryFile !== null) { + $list = array_values(array_diff($list, [$this->sapiEntryFile])); + } + return $list; + } + + private function configureSapiTargets(string|array $value): void + { + try { + $this->sapiTargets = SapiBuildConfiguration::parseTargets($value); + } catch (\InvalidArgumentException $exception) { + $this->error($exception->getMessage()); + } + // The SAPI executable owns main(). Generated TypePHP code is linked as + // an internal Zend module and therefore has no dynamic get_module(). + $this->buildMode = self::BUILD_MODE_SAPI; } /** @return list */ @@ -4440,7 +4590,7 @@ CODE; protected function getProjectYamlLoader(): ProjectYamlLoader { - $this->projectYamlLoader ??= new ProjectYamlLoader($this->phpVersion, fn(string $message): never => $this->error($message)); + $this->projectYamlLoader ??= new ProjectYamlLoader($this->phpVersion, fn (string $message): never => $this->error($message)); $this->projectYamlLoader->setPhpVersion($this->phpVersion); return $this->projectYamlLoader; } @@ -4729,7 +4879,7 @@ CODE; if (!is_string($headerCode)) { throw new \RuntimeException('Cannot read generated arginfo header: ' . $headerFile); } - if (preg_match('/\\bstatic\\s+void\\s+(typephp_release_ast_constants_[A-Za-z0-9_]+)\\s*\\(void\\)/', $headerCode, $releaseMatch)) { + if (preg_match('/\bstatic\s+void\s+(typephp_release_ast_constants_[A-Za-z0-9_]+)\s*\(void\)/', $headerCode, $releaseMatch)) { $this->releaseAstConstantFns[] = $releaseMatch[1]; } $needsAttributeSymbols = str_contains($headerCode, 'zend_add_function_attribute(') @@ -4967,9 +5117,9 @@ CODE; self::TRAIT_ORIGIN_ATTRIBUTE, $traitFullName, ); - if ($existingConstStmt->flags !== $traitStmt->flags || - $this->typeNodeToStringOrNull($existingConstStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) || - !$this->traitMemberExpressionsAreIdentical( + if ($existingConstStmt->flags !== $traitStmt->flags + || $this->typeNodeToStringOrNull($existingConstStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) + || !$this->traitMemberExpressionsAreIdentical( $existingConst->value, $existingOrigin, $const->value, @@ -5007,9 +5157,9 @@ CODE; self::TRAIT_ORIGIN_ATTRIBUTE, $traitFullName, ); - if ($existingPropStmt->flags !== $traitStmt->flags || - $this->typeNodeToStringOrNull($existingPropStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) || - !$this->traitMemberExpressionsAreIdentical( + if ($existingPropStmt->flags !== $traitStmt->flags + || $this->typeNodeToStringOrNull($existingPropStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) + || !$this->traitMemberExpressionsAreIdentical( $existingProp->default, $existingOrigin, $prop->default, @@ -5155,9 +5305,9 @@ CODE; } private function traitMemberExpressionsAreIdentical( - ?Node\Expr $left, + ?Expr $left, string $leftOrigin, - ?Node\Expr $right, + ?Expr $right, string $rightOrigin, ClassDef $usingClass, ?string $declaredType, @@ -5186,7 +5336,7 @@ CODE; } private function evaluateTraitMemberExpression( - Node\Expr $expression, + Expr $expression, string $origin, ClassDef $usingClass, ): mixed { @@ -5195,7 +5345,7 @@ CODE; if ($scope->trait !== null) { return $this->withTraitNameContext( $origin, - fn(): mixed => $this->evaluateCompileTimeExpression($expression, $scope, $usingClass), + fn (): mixed => $this->evaluateCompileTimeExpression($expression, $scope, $usingClass), ); } } @@ -5502,7 +5652,7 @@ CODE; private function reresolveTraitMethodAstLateBoundTypes( ClassDef $usingClassDef, string $traitFullName, - Node\Stmt\ClassMethod $methodStmt + Node\Stmt\ClassMethod $methodStmt, ): void { if (!$this->hasClass($traitFullName)) { return; @@ -5632,7 +5782,7 @@ CODE; string $traitB, string $methodName, Node\Stmt\ClassMethod $a, - Node\Stmt\ClassMethod $b + Node\Stmt\ClassMethod $b, ): void { // Compare visibility if ($a->flags !== $b->flags) { @@ -5800,7 +5950,7 @@ CODE; bool $abstract, ): void { $attributeTarget = $constructor->getAttribute( - \TypePhp\Diagnostics\CompileTimeAttributeDiagnostic::GENERATED_TARGET, + CompileTimeAttributeDiagnostic::GENERATED_TARGET, $constructor, ); if (!$attributeTarget instanceof Node) { @@ -5830,7 +5980,7 @@ CODE; return; } - array_unshift($constructor->stmts, new Node\Stmt\Expression(new Node\Expr\StaticCall( + array_unshift($constructor->stmts, new Node\Stmt\Expression(new Expr\StaticCall( new Node\Name('parent'), new Node\Identifier('__construct'), ))); @@ -5851,9 +6001,9 @@ CODE; } if ($class instanceof Node\Stmt\Class_ && $this->classDef->printerGenerated) { - $available = [...$this->parentPublicProperties($this->classDef->extends), ...\TypePhp\Transform\ClassFieldSelection::ownPublicProperties($class)]; + $available = [...$this->parentPublicProperties($this->classDef->extends), ...Transform\ClassFieldSelection::ownPublicProperties($class)]; try { - $properties = \TypePhp\Transform\ClassFieldSelection::resolve( + $properties = Transform\ClassFieldSelection::resolve( $this->classDef->printerFields, $this->classDef->printerFields === null ? $available @@ -5861,14 +6011,9 @@ CODE; 'Printer', ); } catch (SyntaxError $error) { - throw new SyntaxError(CompileTimeAttributeDiagnostic::format( - $error->getMessage(), - 'Printer', - $class, - $this->file, - ), 0, $error); + throw new SyntaxError(CompileTimeAttributeDiagnostic::format($error->getMessage(), 'Printer', $class, $this->file), 0, $error); } - \TypePhp\Transform\PrinterLowering::rebuildGeneratedMethod( + Transform\PrinterLowering::rebuildGeneratedMethod( $class, $properties, $this->classDef->printerFields, @@ -5876,9 +6021,9 @@ CODE; ); } if ($class instanceof Node\Stmt\Class_ && $this->classDef->arrayableGenerated) { - $available = [...$this->parentPublicProperties($this->classDef->extends), ...\TypePhp\Transform\ClassFieldSelection::ownPublicProperties($class)]; + $available = [...$this->parentPublicProperties($this->classDef->extends), ...Transform\ClassFieldSelection::ownPublicProperties($class)]; try { - $properties = \TypePhp\Transform\ClassFieldSelection::resolve( + $properties = Transform\ClassFieldSelection::resolve( $this->classDef->arrayableFields, $this->classDef->arrayableFields === null ? $available @@ -5886,14 +6031,9 @@ CODE; 'Arrayable', ); } catch (SyntaxError $error) { - throw new SyntaxError(CompileTimeAttributeDiagnostic::format( - $error->getMessage(), - 'Arrayable', - $class, - $this->file, - ), 0, $error); + throw new SyntaxError(CompileTimeAttributeDiagnostic::format($error->getMessage(), 'Arrayable', $class, $this->file), 0, $error); } - \TypePhp\Transform\ArrayableLowering::rebuildGeneratedMethod( + Transform\ArrayableLowering::rebuildGeneratedMethod( $class, $properties, $this->classDef->arrayableFields, @@ -6098,7 +6238,7 @@ CODE; foreach ($this->classDef->constants as $constant) { if ($constant->codegenFinalized || $constant->traitOrigin === '' - || !$constant->valueExpr instanceof Node\Expr + || !$constant->valueExpr instanceof Expr ) { continue; } @@ -6109,7 +6249,7 @@ CODE; foreach ($this->classDef->properties as $property) { $origin = $property->node?->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE); - if (!is_string($origin) || $origin === '' || !$property->defaultExpr instanceof Node\Expr) { + if (!is_string($origin) || $origin === '' || !$property->defaultExpr instanceof Expr) { continue; } $this->withTraitNameContext($origin, function () use ($property): void { @@ -6152,9 +6292,8 @@ CODE; string $fn, FunctionDef $functionDef, string $displayName, - array $implicitMethodArgs = [] - ): string - { + array $implicitMethodArgs = [], + ): string { // A generated ZEND_FUNCTION/ZEND_METHOD is a callback boundary owned by // ZendVM. Native TypePHP code uses C++ exceptions so its local RAII // objects unwind correctly, but that exception must not escape through @@ -6311,12 +6450,12 @@ CODE; * as they are used, rather than held for the long term. */ $indent = $this->getIndent(); - $cppCode = $indent . "const char *value = " . $this->genCharPtr($entryFile, true) . ';' . PHP_EOL; + $cppCode = $indent . 'const char *value = ' . $this->genCharPtr($entryFile, true) . ';' . PHP_EOL; $cppCode .= $indent . 'php::Var &_SERVER = ' . $this->escapeGlobalVar('_SERVER') . ';' . PHP_EOL; - $cppCode .= $indent . '_SERVER.item("PHP_SELF", true) = value;'. PHP_EOL; - $cppCode .= $indent . '_SERVER.item("SCRIPT_NAME", true) = value;'. PHP_EOL; - $cppCode .= $indent . '_SERVER.item("SCRIPT_FILENAME", true) = value;'. PHP_EOL; - $cppCode .= $indent . '_SERVER.item("PATH_TRANSLATED", true) = value;'. PHP_EOL; + $cppCode .= $indent . '_SERVER.item("PHP_SELF", true) = value;' . PHP_EOL; + $cppCode .= $indent . '_SERVER.item("SCRIPT_NAME", true) = value;' . PHP_EOL; + $cppCode .= $indent . '_SERVER.item("SCRIPT_FILENAME", true) = value;' . PHP_EOL; + $cppCode .= $indent . '_SERVER.item("PATH_TRANSLATED", true) = value;' . PHP_EOL; $cppCode .= $indent . '_SERVER.item("DOCUMENT_ROOT", true) = "";' . PHP_EOL; return $cppCode . PHP_EOL; @@ -6611,7 +6750,7 @@ CODE; if ($multiReturn && !$nativeClassMethod) { $forwardArgs = implode(', ', array_map( - fn($argInfo) => $this->canConsumeForwardedArgument($argInfo) + fn ($argInfo) => $this->canConsumeForwardedArgument($argInfo) ? 'php::takeValue(' . $argInfo->name . ')' : $argInfo->name, $this->functionDef->argInfoList, @@ -6637,7 +6776,7 @@ CODE; protected function checkParentMethodCanBeOverridden( Node\Stmt\ClassMethod $v, string $name, - bool $childIsAbstract = false + bool $childIsAbstract = false, ): void { // Zend exempts constructors from the LSP checks against a CONCRETE // parent constructor (subclasses may freely change the construction @@ -6735,7 +6874,7 @@ CODE; MethodDef $childMethodDef, MethodDef $parentMethodDef, string $parentClass, - ?string $childClass = null + ?string $childClass = null, ): void { $className = $childClass ?? $this->getFullClassName(); @@ -6859,7 +6998,7 @@ CODE; NodeAbstract $v, string $className, string $methodName, - string $parentClass + string $parentClass, ): void { $message = "Declaration of `{$className}::{$methodName}()` must be compatible " . "with `{$parentClass}::{$methodName}()`"; @@ -6917,7 +7056,7 @@ CODE; Node\Stmt\ClassMethod $v, string $methodName, MethodDef $childMethodDef, - string $parentClass + string $parentClass, ): void { $parentRef = Reflection::getClass($parentClass); if (!$parentRef || !$parentRef->hasMethod($methodName)) { @@ -7014,7 +7153,7 @@ CODE; private function isParameterTypeOverrideCompatibleWithReflection( ArgInfo $childArg, \ReflectionParameter $parentParam, - string $parentClass + string $parentClass, ): bool { // A child accepting anything is always contravariant-compatible. if ($this->isTopParameterType($childArg)) { @@ -7602,12 +7741,9 @@ CODE; $implementationTypes = $this->getPropertyAcceptedTypes($property); $contractTypes = $this->getPropertyAcceptedTypes($contract); $compatible = match (true) { - $contract->readable && !$contract->writable => - $this->isPropertyTypeSubset($implementationTypes, $contractTypes), - $contract->writable && !$contract->readable => - $this->isPropertyTypeSubset($contractTypes, $implementationTypes), - default => - $this->isPropertyTypeSubset($implementationTypes, $contractTypes) + $contract->readable && !$contract->writable => $this->isPropertyTypeSubset($implementationTypes, $contractTypes), + $contract->writable && !$contract->readable => $this->isPropertyTypeSubset($contractTypes, $implementationTypes), + default => $this->isPropertyTypeSubset($implementationTypes, $contractTypes) && $this->isPropertyTypeSubset($contractTypes, $implementationTypes), }; if (!$compatible) { @@ -8678,8 +8814,7 @@ CODE; ConstantDef $existing, ConstantDef $incoming, string $incomingOrigin, - ): bool - { + ): bool { if ($existing->flags !== $incoming->flags || $existing->type !== $incoming->type || $existing->class !== $incoming->class @@ -8690,7 +8825,7 @@ CODE; $existingOrigin = $existing->traitOrigin !== '' ? $existing->traitOrigin : $this->classDef->getNamespacedName(false); - if ($existing->valueExpr instanceof Node\Expr && $incoming->valueExpr instanceof Node\Expr) { + if ($existing->valueExpr instanceof Expr && $incoming->valueExpr instanceof Expr) { return $this->traitMemberExpressionsAreIdentical( $existing->valueExpr, $existingOrigin, @@ -8711,8 +8846,7 @@ CODE; PropertyDef $existing, PropertyDef $incoming, string $incomingOrigin, - ): bool - { + ): bool { if ($existing->flags !== $incoming->flags || $existing->type !== $incoming->type || $existing->class !== $incoming->class @@ -8727,11 +8861,11 @@ CODE; $existingOrigin = is_string($originAttribute) && $originAttribute !== '' ? $originAttribute : $this->classDef->getNamespacedName(false); - if ($existing->defaultExpr instanceof Node\Expr || $incoming->defaultExpr instanceof Node\Expr) { + if ($existing->defaultExpr instanceof Expr || $incoming->defaultExpr instanceof Expr) { return $this->traitMemberExpressionsAreIdentical( - $existing->defaultExpr instanceof Node\Expr ? $existing->defaultExpr : null, + $existing->defaultExpr instanceof Expr ? $existing->defaultExpr : null, $existingOrigin, - $incoming->defaultExpr instanceof Node\Expr ? $incoming->defaultExpr : null, + $incoming->defaultExpr instanceof Expr ? $incoming->defaultExpr : null, $incomingOrigin, $this->classDef, $existing->type, @@ -8823,5 +8957,4 @@ CODE; substr($fullName, 0, $separator), ); } - }