- Add native source project compilation workflow with project.xml configuration - Implement closure reference capture analysis and type degradation for native objects - Add WASI and nano mode function support checks with comprehensive API exclusions - Introduce Composer native package discovery and validation system - Add build scripts for WASI nano program compilation with browser module support - Implement closure generator enhancements for reference capture handling - Add CLI usage tests and compiler base API validations - Integrate php-nano runtime dependencies and native package management - Add type degradation analysis for closure use variables in generators and functionsmaster
parent
2fe09697fa
commit
b67e40feee
60 changed files with 3534 additions and 158 deletions
@ -0,0 +1,13 @@ |
||||
<?php |
||||
|
||||
#[Native] |
||||
class FixedReferenceNativeObject |
||||
{ |
||||
} |
||||
|
||||
function fixedReferenceNativeObject(): void |
||||
{ |
||||
$value = new FixedReferenceNativeObject(); |
||||
$closure = static function () use (&$value): void {}; |
||||
$closure(); |
||||
} |
||||
@ -0,0 +1,7 @@ |
||||
<?php |
||||
|
||||
function nanoFileStreamMethod(string $path): string |
||||
{ |
||||
$stream = fopen($path, 'rb')->toStream(); |
||||
return $stream->getContents(); |
||||
} |
||||
@ -0,0 +1,6 @@ |
||||
<?php |
||||
|
||||
function main(): void |
||||
{ |
||||
pcntl_setns(1, 0); |
||||
} |
||||
@ -0,0 +1,6 @@ |
||||
<?php |
||||
|
||||
function parseNanoQuery(string $query): array |
||||
{ |
||||
return $query->parseStr(); |
||||
} |
||||
@ -0,0 +1,18 @@ |
||||
<?php |
||||
|
||||
function referenceCaptureAutoDegrade(): Closure |
||||
{ |
||||
$value = 'fixed'; |
||||
return static function () use (&$value): string { |
||||
$value = 'changed'; |
||||
return $value; |
||||
}; |
||||
} |
||||
|
||||
function referenceCaptureParameterDegrade(string $value): Closure |
||||
{ |
||||
return static function () use (&$value): array { |
||||
$value = ['changed']; |
||||
return $value; |
||||
}; |
||||
} |
||||
@ -0,0 +1,11 @@ |
||||
<?php |
||||
|
||||
function main(): bool |
||||
{ |
||||
$integer = std::bigInt(0); |
||||
$float = std::bigFloat('0'); |
||||
$integerFloat = std::bigFloat(1); |
||||
$decimal = std::decimal('0'); |
||||
|
||||
return $integer->toBool() || $float->toBool() || $integerFloat->toBool() || $decimal->toBool(); |
||||
} |
||||
@ -0,0 +1,6 @@ |
||||
<?php |
||||
|
||||
function main(): void |
||||
{ |
||||
stream_socket_client('tcp://127.0.0.1:80'); |
||||
} |
||||
@ -0,0 +1,44 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Tests\Analysis; |
||||
|
||||
use PhpParser\Node\Expr\Closure; |
||||
use PhpParser\Node\Stmt\Function_; |
||||
use PhpParser\NodeFinder; |
||||
use PhpParser\ParserFactory; |
||||
use PHPUnit\Framework\TestCase; |
||||
use TypePhp\Analysis\ReferenceCaptureAnalyzer; |
||||
|
||||
final class ReferenceCaptureAnalyzerTest extends TestCase |
||||
{ |
||||
public function testCollectsOnlyCapturesOwnedByTheCurrentFunctionScope(): void |
||||
{ |
||||
$nodes = (new ParserFactory())->createForHostVersion()->parse(<<<'PHP' |
||||
<?php |
||||
function example(): void { |
||||
static $staticValue = ''; |
||||
global $globalValue; |
||||
$closure = function () use (&$outer, $copy): void { |
||||
$nested = function () use (&$inner): void {}; |
||||
}; |
||||
} |
||||
PHP); |
||||
self::assertNotNull($nodes); |
||||
|
||||
$finder = new NodeFinder(); |
||||
$function = $finder->findFirstInstanceOf($nodes, Function_::class); |
||||
self::assertInstanceOf(Function_::class, $function); |
||||
|
||||
$analysis = (new ReferenceCaptureAnalyzer())->analyze($function->stmts); |
||||
self::assertSame(['outer' => true], $analysis['captures']); |
||||
self::assertSame( |
||||
['staticValue' => true, 'globalValue' => true], |
||||
$analysis['nonLocals'], |
||||
); |
||||
|
||||
$closure = $finder->findFirstInstanceOf($function->stmts, Closure::class); |
||||
self::assertInstanceOf(Closure::class, $closure); |
||||
$nestedAnalysis = (new ReferenceCaptureAnalyzer())->analyze($closure->stmts); |
||||
self::assertSame(['inner' => true], $nestedAnalysis['captures']); |
||||
} |
||||
} |
||||
@ -0,0 +1,109 @@ |
||||
<?php |
||||
|
||||
namespace TypePhpTest\Build; |
||||
|
||||
use Composer\InstalledVersions; |
||||
use PHPUnit\Framework\TestCase; |
||||
use RuntimeException; |
||||
use TypePhp\Build\ComposerNativePackage; |
||||
|
||||
final class ComposerNativePackageTest extends TestCase |
||||
{ |
||||
/** @var array<string, mixed> */ |
||||
private array $installedVersions; |
||||
private string $directory; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->installedVersions = InstalledVersions::getRawData(); |
||||
$this->directory = sys_get_temp_dir() . '/typephp-native-package-' . bin2hex(random_bytes(6)); |
||||
mkdir($this->directory . '/src', 0777, true); |
||||
file_put_contents($this->directory . '/src/example.c', 'int typephp_example(void) { return 1; }'); |
||||
} |
||||
|
||||
protected function tearDown(): void |
||||
{ |
||||
InstalledVersions::reload($this->installedVersions); |
||||
@unlink($this->directory . '/src/example.c'); |
||||
@unlink($this->directory . '/composer.json'); |
||||
@rmdir($this->directory . '/src'); |
||||
@rmdir($this->directory); |
||||
} |
||||
|
||||
public function testLoadsStaticExtensionMetadata(): void |
||||
{ |
||||
$this->installFixture(true); |
||||
|
||||
$package = ComposerNativePackage::load('swoole/php-ext-example'); |
||||
|
||||
self::assertSame('extension', $package->kind); |
||||
self::assertSame('example', $package->extensionName); |
||||
self::assertSame('example_module_entry', $package->extensionModuleEntry); |
||||
self::assertSame([realpath($this->directory . '/src/example.c')], $package->sources); |
||||
} |
||||
|
||||
public function testStaticExtensionMustRequireNanoRuntime(): void |
||||
{ |
||||
$this->installFixture(false); |
||||
|
||||
$this->expectException(RuntimeException::class); |
||||
$this->expectExceptionMessage('must require `swoole/php-nano`'); |
||||
ComposerNativePackage::load('swoole/php-ext-example'); |
||||
} |
||||
|
||||
public function testObsoleteExternalStandardPackageIsRejected(): void |
||||
{ |
||||
$this->expectException(RuntimeException::class); |
||||
$this->expectExceptionMessage('standard extension is built into'); |
||||
ComposerNativePackage::load('swoole/php-ext-standard'); |
||||
} |
||||
|
||||
private function installFixture(bool $requireRuntime): void |
||||
{ |
||||
$manifest = [ |
||||
'name' => 'swoole/php-ext-example', |
||||
'require' => $requireRuntime ? ['swoole/php-nano' => '^8.6@dev'] : [], |
||||
'extra' => [ |
||||
'typephp-native' => [ |
||||
'kind' => 'extension', |
||||
'abi' => 80600, |
||||
'c-standard' => 11, |
||||
'cxx-standard' => 17, |
||||
'include-dirs' => ['src'], |
||||
'sources' => ['src/example.c'], |
||||
'extension' => [ |
||||
'name' => 'example', |
||||
'module-entry' => 'example_module_entry', |
||||
], |
||||
], |
||||
], |
||||
]; |
||||
file_put_contents( |
||||
$this->directory . '/composer.json', |
||||
json_encode($manifest, JSON_THROW_ON_ERROR), |
||||
); |
||||
InstalledVersions::reload([ |
||||
'root' => [ |
||||
'name' => 'typephp/test', |
||||
'pretty_version' => 'dev-main', |
||||
'version' => 'dev-main', |
||||
'reference' => null, |
||||
'type' => 'project', |
||||
'install_path' => $this->directory, |
||||
'aliases' => [], |
||||
'dev' => true, |
||||
], |
||||
'versions' => [ |
||||
'swoole/php-ext-example' => [ |
||||
'pretty_version' => '0.1.0', |
||||
'version' => '0.1.0.0', |
||||
'reference' => null, |
||||
'type' => 'library', |
||||
'install_path' => $this->directory, |
||||
'aliases' => [], |
||||
'dev_requirement' => false, |
||||
], |
||||
], |
||||
]); |
||||
} |
||||
} |
||||
@ -0,0 +1,31 @@ |
||||
<?php |
||||
|
||||
namespace TypePhpTest\Build; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use TypePhp\Build\NanoBuildBackend; |
||||
|
||||
final class NanoBuildBackendTest extends TestCase |
||||
{ |
||||
public function testWindowsKeepsTheHostDllBuildBackend(): void |
||||
{ |
||||
self::assertSame(NanoBuildBackend::WINDOWS_DLL, NanoBuildBackend::forHost('Windows')); |
||||
self::assertFalse(NanoBuildBackend::composesRuntimeSources('Windows')); |
||||
} |
||||
|
||||
/** @dataProvider nonWindowsHosts */ |
||||
public function testNonWindowsHostsComposeComposerRuntimeSources(string $osFamily): void |
||||
{ |
||||
self::assertSame(NanoBuildBackend::COMPOSER_SOURCES, NanoBuildBackend::forHost($osFamily)); |
||||
self::assertTrue(NanoBuildBackend::composesRuntimeSources($osFamily)); |
||||
} |
||||
|
||||
public static function nonWindowsHosts(): array |
||||
{ |
||||
return [ |
||||
['Linux'], |
||||
['Darwin'], |
||||
['BSD'], |
||||
]; |
||||
} |
||||
} |
||||
@ -0,0 +1,132 @@ |
||||
<?php |
||||
|
||||
namespace TypePhpTest\Build; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use RuntimeException; |
||||
use TypePhp\Build\NativeDependencyAuditor; |
||||
|
||||
final class NativeDependencyAuditorTest extends TestCase |
||||
{ |
||||
public function testToolchainRuntimeLibrariesAreAllowedForWasip2(): void |
||||
{ |
||||
(new NativeDependencyAuditor())->assertLinkFlags( |
||||
'wasip2', |
||||
['-fwasm-exceptions', '-lsetjmp', '-lunwind', '-Wl,--gc-sections'], |
||||
); |
||||
self::addToAssertionCount(1); |
||||
} |
||||
|
||||
public function testExternalLibraryIsRejected(): void |
||||
{ |
||||
$this->expectException(RuntimeException::class); |
||||
$this->expectExceptionMessage('must not link external library `-lcurl`'); |
||||
(new NativeDependencyAuditor())->assertLinkFlags('native', ['-lcurl']); |
||||
} |
||||
|
||||
public function testNativeForbiddenSymbolIsRejected(): void |
||||
{ |
||||
$this->expectException(RuntimeException::class); |
||||
$this->expectExceptionMessage('mmap'); |
||||
(new NativeDependencyAuditor())->assertUndefinedSymbols( |
||||
'native', |
||||
" U malloc@GLIBC_2.2.5\n U mmap@GLIBC_2.2.5\n", |
||||
); |
||||
} |
||||
|
||||
/** @dataProvider forbiddenNativeCapabilityProvider */ |
||||
public function testNativeCapabilityFamiliesAreRejected(string $symbol): void |
||||
{ |
||||
$this->expectException(RuntimeException::class); |
||||
$this->expectExceptionMessage($symbol); |
||||
(new NativeDependencyAuditor())->assertUndefinedSymbols( |
||||
'native', |
||||
" U {$symbol}@GLIBC_2.2.5\n", |
||||
); |
||||
} |
||||
|
||||
public static function forbiddenNativeCapabilityProvider(): array |
||||
{ |
||||
return [ |
||||
'process pipe' => ['pipe'], |
||||
'descriptor ioctl' => ['ioctl'], |
||||
'descriptor selection' => ['select'], |
||||
'network socket' => ['socket'], |
||||
'network connection' => ['connect'], |
||||
'system logging' => ['syslog'], |
||||
]; |
||||
} |
||||
|
||||
public function testWasip2FilesystemImportsAreAllowed(): void |
||||
{ |
||||
(new NativeDependencyAuditor())->assertUndefinedSymbols( |
||||
'wasip2', |
||||
" U __imported_wasi_snapshot_preview1_fd_write\n" |
||||
. " U __imported_wasi_snapshot_preview1_fd_read\n" |
||||
. " U __imported_wasi_snapshot_preview1_path_open\n", |
||||
); |
||||
self::addToAssertionCount(1); |
||||
} |
||||
|
||||
public function testNativeFilesystemSymbolsAreAllowed(): void |
||||
{ |
||||
(new NativeDependencyAuditor())->assertUndefinedSymbols( |
||||
'native', |
||||
" U fopen@GLIBC_2.2.5\n" |
||||
. " U stat@GLIBC_2.2.5\n" |
||||
. " U read@GLIBC_2.2.5\n", |
||||
); |
||||
self::addToAssertionCount(1); |
||||
} |
||||
|
||||
public function testOnlyNonPosixExceptionsNeedAnAllowlistEntry(): void |
||||
{ |
||||
self::assertContains('flock', NativeDependencyAuditor::NON_POSIX_HOST_FUNCTION_ALLOWLIST); |
||||
foreach (['fopen', 'strdup', 'dup', 'dup2', 'mkstemp', 'chown', 'getuid'] as $standardName) { |
||||
self::assertNotContains( |
||||
$standardName, |
||||
NativeDependencyAuditor::NON_POSIX_HOST_FUNCTION_ALLOWLIST, |
||||
); |
||||
} |
||||
self::assertNotContains('socket', NativeDependencyAuditor::NON_POSIX_HOST_FUNCTION_ALLOWLIST); |
||||
} |
||||
|
||||
public function testConsoleOutputImportsAreAllowed(): void |
||||
{ |
||||
(new NativeDependencyAuditor())->assertUndefinedSymbols( |
||||
'wasip2', |
||||
" U __imported_wasi_snapshot_preview1_fd_write\n" |
||||
. " U __imported_wasi_snapshot_preview1_proc_exit\n", |
||||
); |
||||
self::addToAssertionCount(1); |
||||
} |
||||
|
||||
public function testWasip2RejectsSocketAndProcessControlImports(): void |
||||
{ |
||||
$this->expectException(RuntimeException::class); |
||||
$this->expectExceptionMessage('proc_raise'); |
||||
(new NativeDependencyAuditor())->assertUndefinedSymbols( |
||||
'wasip2', |
||||
" U __imported_wasi_snapshot_preview1_fd_write\n" |
||||
. " U __imported_wasi_snapshot_preview1_sock_recv\n" |
||||
. " U __imported_wasi_snapshot_preview1_proc_raise\n", |
||||
); |
||||
} |
||||
|
||||
public function testStandardLibraryClockSleepAndEntropyImportsAreAllowed(): void |
||||
{ |
||||
$auditor = new NativeDependencyAuditor(); |
||||
$auditor->assertUndefinedSymbols( |
||||
'native', |
||||
" U clock_gettime@GLIBC_2.17\n" |
||||
. " U nanosleep@GLIBC_2.2.5\n", |
||||
); |
||||
$auditor->assertUndefinedSymbols( |
||||
'wasip2', |
||||
" U __imported_wasi_snapshot_preview1_clock_time_get\n" |
||||
. " U __imported_wasi_snapshot_preview1_poll_oneoff\n" |
||||
. " U __imported_wasi_snapshot_preview1_random_get\n", |
||||
); |
||||
self::addToAssertionCount(1); |
||||
} |
||||
} |
||||
@ -0,0 +1,72 @@ |
||||
<?php |
||||
|
||||
namespace TypePhpTest\Build; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use RuntimeException; |
||||
use TypePhp\Build\NativeSourceProjectConfig; |
||||
|
||||
final class NativeSourceProjectConfigTest extends TestCase |
||||
{ |
||||
private string $directory; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->directory = sys_get_temp_dir() . '/typephp-native-project-' . bin2hex(random_bytes(6)); |
||||
mkdir($this->directory . '/generated', 0777, true); |
||||
file_put_contents($this->directory . '/generated/runtime.c', 'int native_c(void) { return 1; }'); |
||||
file_put_contents($this->directory . '/generated/main.cpp', 'int main() { return 0; }'); |
||||
file_put_contents($this->directory . '/generated/main.php', '<?php function main(): void {}'); |
||||
} |
||||
|
||||
protected function tearDown(): void |
||||
{ |
||||
@unlink($this->directory . '/generated/runtime.c'); |
||||
@unlink($this->directory . '/generated/main.cpp'); |
||||
@unlink($this->directory . '/generated/main.php'); |
||||
@unlink($this->directory . '/generated/invalid.txt'); |
||||
@unlink($this->directory . '/project.xml'); |
||||
@rmdir($this->directory . '/generated'); |
||||
@rmdir($this->directory); |
||||
} |
||||
|
||||
public function testPhpCAndCppSourcesAreAccepted(): void |
||||
{ |
||||
file_put_contents($this->directory . '/project.xml', <<<'XML' |
||||
<?xml version="1.0"?> |
||||
<project mode="native" name="mixed-source"> |
||||
<sources> |
||||
<source>generated/main.php</source> |
||||
<source>generated/runtime.c</source> |
||||
<source>generated/main.cpp</source> |
||||
</sources> |
||||
</project> |
||||
XML); |
||||
|
||||
$config = NativeSourceProjectConfig::load($this->directory . '/project.xml'); |
||||
|
||||
self::assertSame('cc', $config->cCompiler); |
||||
self::assertSame('c++', $config->compiler); |
||||
self::assertSame([ |
||||
realpath($this->directory . '/generated/main.php'), |
||||
], $config->phpSources); |
||||
self::assertSame([ |
||||
realpath($this->directory . '/generated/runtime.c'), |
||||
realpath($this->directory . '/generated/main.cpp'), |
||||
], $config->sources); |
||||
} |
||||
|
||||
public function testNonNativeSourceIsRejected(): void |
||||
{ |
||||
file_put_contents($this->directory . '/generated/invalid.txt', 'invalid'); |
||||
file_put_contents($this->directory . '/project.xml', <<<'XML' |
||||
<project mode="native"> |
||||
<sources><source>generated/invalid.txt</source></sources> |
||||
</project> |
||||
XML); |
||||
|
||||
$this->expectException(RuntimeException::class); |
||||
$this->expectExceptionMessage('must be PHP, C, or C++'); |
||||
NativeSourceProjectConfig::load($this->directory . '/project.xml'); |
||||
} |
||||
} |
||||
@ -0,0 +1,54 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Tests; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
|
||||
final class CliUsageTest extends TestCase |
||||
{ |
||||
public function testHelpSeparatesSubcommandsFromCompilationOptions(): void |
||||
{ |
||||
$process = proc_open( |
||||
[PHP_BINARY, TYPEPHP_ROOT_PATH . '/bin/tpc.php', '--help'], |
||||
[ |
||||
1 => ['pipe', 'w'], |
||||
2 => ['pipe', 'w'], |
||||
], |
||||
$pipes, |
||||
TYPEPHP_ROOT_PATH, |
||||
); |
||||
self::assertIsResource($process); |
||||
|
||||
$output = stream_get_contents($pipes[1]); |
||||
$error = stream_get_contents($pipes[2]); |
||||
fclose($pipes[1]); |
||||
fclose($pipes[2]); |
||||
$status = proc_close($process); |
||||
|
||||
self::assertSame(0, $status, $error); |
||||
self::assertIsString($output); |
||||
|
||||
$subcommands = self::section($output, 'SUBCOMMANDS:', 'EXAMPLES:'); |
||||
self::assertStringContainsString('--gen-python-helper', $subcommands); |
||||
self::assertStringContainsString('--convert-python-to-php', $subcommands); |
||||
self::assertStringContainsString('--generate-completion=bash', $subcommands); |
||||
|
||||
$compilationOptions = self::section($output, 'COMPILATION OPTIONS:', 'GENERAL OPTIONS:'); |
||||
self::assertStringContainsString('--nano', $compilationOptions); |
||||
self::assertStringContainsString('--wasm[=browser|component]', $compilationOptions); |
||||
self::assertStringNotContainsString('--gen-python-helper', $compilationOptions); |
||||
self::assertStringNotContainsString('--convert-python-to-php', $compilationOptions); |
||||
self::assertStringNotContainsString('--generate-completion', $compilationOptions); |
||||
} |
||||
|
||||
private static function section(string $output, string $start, string $end): string |
||||
{ |
||||
$startOffset = strpos($output, $start); |
||||
$endOffset = strpos($output, $end); |
||||
self::assertNotFalse($startOffset); |
||||
self::assertNotFalse($endOffset); |
||||
self::assertGreaterThan($startOffset, $endOffset); |
||||
|
||||
return substr($output, $startOffset, $endOffset - $startOffset); |
||||
} |
||||
} |
||||
@ -0,0 +1,271 @@ |
||||
<?php |
||||
|
||||
use PhpParser\Node\Expr\FuncCall; |
||||
use PhpParser\Node\Name; |
||||
use TypePhp\CompilerTest; |
||||
use TypePhp\Exception\TestError; |
||||
|
||||
final class NanoCapabilityPolicyCompiler extends CompilerTest |
||||
{ |
||||
public function enableNanoForTest(): void |
||||
{ |
||||
$this->nanoMode = true; |
||||
$this->forTest = true; |
||||
$this->file = 'nano-policy.php'; |
||||
} |
||||
|
||||
public function enableWasiForTest(): void |
||||
{ |
||||
$this->nanoMode = true; |
||||
$this->nanoPolicyMode = true; |
||||
$this->targetPlatform = 'wasm32-wasip2'; |
||||
$this->forTest = true; |
||||
$this->file = 'nano-policy.php'; |
||||
} |
||||
|
||||
public function enableFullRuntimeNanoPolicyForTest(): void |
||||
{ |
||||
$this->nanoMode = false; |
||||
$this->nanoPolicyMode = true; |
||||
$this->forTest = true; |
||||
$this->file = 'nano-policy.php'; |
||||
} |
||||
|
||||
public function validateNanoFunction(string $name): void |
||||
{ |
||||
$this->assertNanoFunctionSupported(new FuncCall(new Name($name)), $name); |
||||
} |
||||
|
||||
public function validateWasiFunction(string $name): void |
||||
{ |
||||
$this->assertWasiFunctionSupported(new FuncCall(new Name($name)), $name); |
||||
} |
||||
|
||||
public function forgetBuildTimeFunction(string $name): void |
||||
{ |
||||
unset($this->internalFunctions[$name]); |
||||
} |
||||
} |
||||
|
||||
final class NanoCapabilityPolicyTest extends BaseTest |
||||
{ |
||||
public function testRejectsForbiddenDirectCallMissingFromBuildTimePhp(): void |
||||
{ |
||||
global $translator; |
||||
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); |
||||
$compiler->enableNanoForTest(); |
||||
$compiler->forgetBuildTimeFunction('pcntl_setns'); |
||||
$translator = $compiler; |
||||
$source = __DIR__ . '/../code/nano-unavailable-host-function.php'; |
||||
$compiler->addFiles([$source]); |
||||
$compiler->prepareFile($source); |
||||
|
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Function `pcntl_setns` is not supported in nano mode'); |
||||
$compiler->convertFile($source); |
||||
} |
||||
|
||||
public function testRejectsHostCapabilityFunctions(): void |
||||
{ |
||||
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); |
||||
$compiler->enableNanoForTest(); |
||||
|
||||
foreach (['shell_exec', 'stream_socket_client', 'stream_select', 'parse_str'] as $name) { |
||||
try { |
||||
$compiler->validateNanoFunction($name); |
||||
self::fail("{$name} was accepted"); |
||||
} catch (TestError $error) { |
||||
self::assertStringContainsString("Function `{$name}` is not supported in nano mode", $error->getMessage()); |
||||
} |
||||
} |
||||
} |
||||
|
||||
public function testFullRuntimeNanoPolicyRejectsExternalCommandsOnly(): void |
||||
{ |
||||
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); |
||||
$compiler->enableFullRuntimeNanoPolicyForTest(); |
||||
|
||||
foreach (['exec', 'passthru', 'pcntl_exec', 'popen', 'proc_open', 'proc_terminate', 'shell_exec', 'system'] as $name) { |
||||
try { |
||||
$compiler->validateNanoFunction($name); |
||||
self::fail("{$name} was accepted"); |
||||
} catch (TestError $error) { |
||||
self::assertStringContainsString("Function `{$name}` is not supported in nano mode", $error->getMessage()); |
||||
} |
||||
} |
||||
|
||||
// Windows uses the complete PHP/PHPX DLL runtime. The php-nano-only |
||||
// capability reductions are therefore not applied to that target. |
||||
foreach (['getenv', 'parse_str', 'stream_socket_client'] as $name) { |
||||
$compiler->validateNanoFunction($name); |
||||
} |
||||
self::addToAssertionCount(3); |
||||
} |
||||
|
||||
public function testFullRuntimeNanoEntryCallsGeneratedMainWithoutEval(): void |
||||
{ |
||||
global $translator; |
||||
$previousTranslator = $translator ?? null; |
||||
$directory = sys_get_temp_dir() . '/typephp_nano_policy_' . bin2hex(random_bytes(6)); |
||||
mkdir($directory, 0777, true); |
||||
$source = $directory . '/main.php'; |
||||
file_put_contents($source, "<?php\nfunction main(): void {}\n");
|
||||
|
||||
try { |
||||
$compiler = new NanoCapabilityPolicyCompiler($directory); |
||||
$compiler->enableFullRuntimeNanoPolicyForTest(); |
||||
$compiler->setBuildMode(\TypePhp\CompilerBase::BUILD_MODE_BIN); |
||||
$compiler->setTargetName('nano_policy_entry'); |
||||
$translator = $compiler; |
||||
$compiler->addFiles([$source]); |
||||
$compiler->prepareFile($source); |
||||
$compiler->convertFile($source); |
||||
$extension = file_get_contents($compiler->genExtension()); |
||||
|
||||
self::assertIsString($extension); |
||||
self::assertStringContainsString('php_main();', $extension); |
||||
self::assertStringNotContainsString('php::eval(', $extension); |
||||
self::assertStringContainsString( |
||||
'zend_disable_functions("exec,passthru,pcntl_exec,popen,proc_close,proc_get_status,proc_nice,proc_open,proc_terminate,shell_exec,system")', |
||||
$extension, |
||||
); |
||||
self::assertStringContainsString('_SERVER.item("SCRIPT_FILENAME", true)', $extension); |
||||
} finally { |
||||
$translator = $previousTranslator; |
||||
self::removeDirectory($directory); |
||||
} |
||||
} |
||||
|
||||
private static function removeDirectory(string $directory): void |
||||
{ |
||||
if (!is_dir($directory)) { |
||||
return; |
||||
} |
||||
foreach (array_diff(scandir($directory), ['.', '..']) as $entry) { |
||||
$path = $directory . DIRECTORY_SEPARATOR . $entry; |
||||
if (is_dir($path)) { |
||||
self::removeDirectory($path); |
||||
} else { |
||||
unlink($path); |
||||
} |
||||
} |
||||
rmdir($directory); |
||||
} |
||||
|
||||
public function testKeepsFileStreamsAndFileHashesAvailable(): void |
||||
{ |
||||
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); |
||||
$compiler->enableNanoForTest(); |
||||
|
||||
foreach (['fopen', 'file_get_contents', 'file_put_contents', 'is_file', 'realpath', 'hash_file', 'stream_get_contents', 'flock', 'umask', 'chown'] as $name) { |
||||
$compiler->validateNanoFunction($name); |
||||
} |
||||
self::addToAssertionCount(10); |
||||
} |
||||
|
||||
public function testWasiOnlyRejectsItsMissingFileCapabilities(): void |
||||
{ |
||||
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); |
||||
$compiler->enableWasiForTest(); |
||||
|
||||
foreach (['flock', 'umask', 'chown', 'stream_socket_client', 'gethostbyname', 'dns_get_record'] as $name) { |
||||
try { |
||||
$compiler->validateWasiFunction($name); |
||||
self::fail("{$name} was accepted"); |
||||
} catch (TestError $error) { |
||||
self::assertStringContainsString("Function `{$name}` is not supported by the WASI target", $error->getMessage()); |
||||
} |
||||
} |
||||
|
||||
foreach (['fopen', 'file_get_contents', 'hash_file'] as $name) { |
||||
$compiler->validateWasiFunction($name); |
||||
} |
||||
self::addToAssertionCount(3); |
||||
} |
||||
|
||||
public function testWasiUnsupportedDirectCallFailsBeforeCodeGeneration(): void |
||||
{ |
||||
global $translator; |
||||
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); |
||||
$compiler->enableWasiForTest(); |
||||
$translator = $compiler; |
||||
$source = __DIR__ . '/../code/wasi-unavailable-host-function.php'; |
||||
$compiler->addFiles([$source]); |
||||
$compiler->prepareFile($source); |
||||
|
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage( |
||||
'Function `stream_socket_client` is not supported by the WASI target', |
||||
); |
||||
$compiler->convertFile($source); |
||||
} |
||||
|
||||
public function testNanoKeepsTypedFileStreamMethods(): void |
||||
{ |
||||
global $translator; |
||||
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); |
||||
$compiler->enableNanoForTest(); |
||||
$translator = $compiler; |
||||
$source = __DIR__ . '/../code/nano-file-stream-method.php'; |
||||
$compiler->addFiles([$source]); |
||||
$compiler->prepareFile($source); |
||||
|
||||
$code = file_get_contents($compiler->convertFile($source)); |
||||
self::assertIsString($code); |
||||
self::assertStringContainsString('php::toStream(', $code); |
||||
self::assertStringContainsString('php::call(', $code); |
||||
} |
||||
|
||||
public function testRejectsUnavailableStringMethodAtCompileTime(): void |
||||
{ |
||||
global $translator; |
||||
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); |
||||
$compiler->enableNanoForTest(); |
||||
$translator = $compiler; |
||||
$source = __DIR__ . '/../code/nano-unavailable-string-method.php'; |
||||
$compiler->addFiles([$source]); |
||||
$compiler->prepareFile($source); |
||||
|
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage('Function `parse_str` is not supported in nano mode'); |
||||
$compiler->convertFile($source); |
||||
} |
||||
|
||||
public function testKeepsZendIniFunctionsAvailable(): void |
||||
{ |
||||
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); |
||||
$compiler->enableNanoForTest(); |
||||
$compiler->validateNanoFunction('ini_get'); |
||||
$compiler->validateNanoFunction('ini_set'); |
||||
self::addToAssertionCount(2); |
||||
} |
||||
|
||||
public function testKeepsCompilerCtypeIntrinsicAvailable(): void |
||||
{ |
||||
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); |
||||
$compiler->enableNanoForTest(); |
||||
$compiler->validateNanoFunction('ctype_digit'); |
||||
$this->addToAssertionCount(1); |
||||
} |
||||
|
||||
public function testKeepsStandardCppTimeFunctionsAvailable(): void |
||||
{ |
||||
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); |
||||
$compiler->enableNanoForTest(); |
||||
|
||||
foreach (['sleep', 'usleep', 'time_nanosleep', 'time_sleep_until', 'hrtime', 'microtime'] as $name) { |
||||
$compiler->validateNanoFunction($name); |
||||
} |
||||
$this->addToAssertionCount(6); |
||||
} |
||||
|
||||
public function testKeepsStandardCppRandomFunctionsAvailable(): void |
||||
{ |
||||
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH); |
||||
$compiler->enableNanoForTest(); |
||||
|
||||
$compiler->validateNanoFunction('random_bytes'); |
||||
$compiler->validateNanoFunction('random_int'); |
||||
$this->addToAssertionCount(2); |
||||
} |
||||
} |
||||
@ -0,0 +1,142 @@ |
||||
<?php |
||||
|
||||
namespace TypePhpTest\Transform; |
||||
|
||||
use PhpParser\Node; |
||||
use PhpParser\NodeTraverser; |
||||
use PhpParser\NodeVisitor\NameResolver; |
||||
use PhpParser\ParserFactory; |
||||
use PHPUnit\Framework\Attributes\DataProvider; |
||||
use PHPUnit\Framework\TestCase; |
||||
use RuntimeException; |
||||
use TypePhp\Transform\NanoSyntaxValidationVisitor; |
||||
|
||||
final class NanoSyntaxValidationVisitorTest extends TestCase |
||||
{ |
||||
/** @return iterable<string, array{string, string}> */ |
||||
public static function unsupportedSyntax(): iterable |
||||
{ |
||||
yield 'eval' => ['<?php function run(): void { eval("return 1;"); }', '`eval`']; |
||||
yield 'include' => ['<?php function run(): void { include "a.php"; }', '`include`']; |
||||
yield 'include once' => ['<?php function run(): void { include_once "a.php"; }', '`include_once`']; |
||||
yield 'require' => ['<?php function run(): void { require "a.php"; }', '`require`']; |
||||
yield 'require once' => ['<?php function run(): void { require_once "a.php"; }', '`require_once`']; |
||||
yield 'backticks' => ['<?php function run(): string { return `uname`; }', 'Backtick shell execution']; |
||||
yield 'anonymous class' => ['<?php function run(): object { return new class {}; }', 'Anonymous classes']; |
||||
yield 'yield' => ['<?php function values(): iterable { yield 1; }', 'Fiber and Generator']; |
||||
yield 'yield from' => ['<?php function values(): iterable { yield from [1]; }', 'Fiber and Generator']; |
||||
yield 'generator closure' => [ |
||||
'<?php function values(): Closure { return function (): iterable { yield 1; }; }', |
||||
'Fiber and Generator', |
||||
]; |
||||
yield 'generator arrow function' => [ |
||||
'<?php function values(): Closure { return fn (): iterable => yield 1; }', |
||||
'Fiber and Generator', |
||||
]; |
||||
yield 'Fiber class' => [ |
||||
'<?php function run(): object { return new Fiber(static function (): void {}); }', |
||||
'Class `Fiber` is not supported', |
||||
]; |
||||
yield 'Generator type' => [ |
||||
'<?php function values(): Generator { throw new RuntimeException(); }', |
||||
'Class `Generator` is not supported', |
||||
]; |
||||
yield 'ReflectionGenerator class' => [ |
||||
'<?php function inspect(object $value): object { return new ReflectionGenerator($value); }', |
||||
'Class `ReflectionGenerator` is not supported', |
||||
]; |
||||
} |
||||
|
||||
#[DataProvider('unsupportedSyntax')] |
||||
public function testRejectsRuntimeCompiledSyntax(string $source, string $expected): void |
||||
{ |
||||
$parser = (new ParserFactory())->createForNewestSupportedVersion(); |
||||
$nodes = $parser->parse($source); |
||||
self::assertNotNull($nodes); |
||||
|
||||
$traverser = new NodeTraverser(); |
||||
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); |
||||
$traverser->addVisitor(new NanoSyntaxValidationVisitor( |
||||
static function (Node $node, string $message): never { |
||||
throw new RuntimeException($message . ':' . $node->getStartLine()); |
||||
}, |
||||
)); |
||||
|
||||
$this->expectException(RuntimeException::class); |
||||
$this->expectExceptionMessage($expected); |
||||
$traverser->traverse($nodes); |
||||
} |
||||
|
||||
public function testAcceptsNamedClassesAndOrdinaryObjectConstruction(): void |
||||
{ |
||||
$parser = (new ParserFactory())->createForNewestSupportedVersion(); |
||||
$nodes = $parser->parse( |
||||
'<?php class Value {} function run(): object { return new Value(); }', |
||||
); |
||||
self::assertNotNull($nodes); |
||||
|
||||
$traverser = new NodeTraverser(); |
||||
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); |
||||
$traverser->addVisitor(new NanoSyntaxValidationVisitor( |
||||
static function (Node $node, string $message): never { |
||||
throw new RuntimeException($message . ':' . $node->getStartLine()); |
||||
}, |
||||
)); |
||||
|
||||
self::assertCount(2, $traverser->traverse($nodes)); |
||||
} |
||||
|
||||
public function testAcceptsNamespacedUserClassNamedFiber(): void |
||||
{ |
||||
$parser = (new ParserFactory())->createForNewestSupportedVersion(); |
||||
$nodes = $parser->parse( |
||||
'<?php namespace App; class Fiber {} function run(): Fiber { return new Fiber(); }', |
||||
); |
||||
self::assertNotNull($nodes); |
||||
|
||||
$traverser = new NodeTraverser(); |
||||
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); |
||||
$traverser->addVisitor(new NanoSyntaxValidationVisitor( |
||||
static function (Node $node, string $message): never { |
||||
throw new RuntimeException($message . ':' . $node->getStartLine()); |
||||
}, |
||||
)); |
||||
|
||||
self::assertCount(1, $traverser->traverse($nodes)); |
||||
} |
||||
|
||||
public function testFullRuntimeNanoPolicyKeepsGeneratorsButRejectsVmEntrySyntax(): void |
||||
{ |
||||
$parser = (new ParserFactory())->createForNewestSupportedVersion(); |
||||
$accepted = $parser->parse( |
||||
'<?php function values(): Generator { yield 1; }', |
||||
); |
||||
self::assertNotNull($accepted); |
||||
|
||||
$traverser = new NodeTraverser(); |
||||
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); |
||||
$traverser->addVisitor(new NanoSyntaxValidationVisitor( |
||||
static function (Node $node, string $message): never { |
||||
throw new RuntimeException($message . ':' . $node->getStartLine()); |
||||
}, |
||||
false, |
||||
)); |
||||
self::assertCount(1, $traverser->traverse($accepted)); |
||||
|
||||
foreach ([ |
||||
'<?php eval("return 1;");', |
||||
'<?php require "a.php";', |
||||
'<?php `uname`;', |
||||
'<?php $value = new class {};', |
||||
] as $source) { |
||||
$nodes = $parser->parse($source); |
||||
self::assertNotNull($nodes); |
||||
try { |
||||
$traverser->traverse($nodes); |
||||
self::fail("Nano policy accepted unsupported source: {$source}"); |
||||
} catch (RuntimeException) { |
||||
self::addToAssertionCount(1); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,85 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP. |
||||
* |
||||
* @link https://www.swoole.com/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Analysis; |
||||
|
||||
use PhpParser\Node; |
||||
use PhpParser\Node\Expr; |
||||
use PhpParser\Node\FunctionLike; |
||||
use PhpParser\Node\Stmt; |
||||
|
||||
/** |
||||
* Finds locals which need dynamic storage because a child Closure captures |
||||
* them by reference. Nested function bodies are separate variable scopes and |
||||
* are deliberately analyzed when their own FunctionContext is created. |
||||
*/ |
||||
final class ReferenceCaptureAnalyzer |
||||
{ |
||||
/** |
||||
* @param Node|list<Node>|null $body |
||||
* @return array{captures: array<string, true>, nonLocals: array<string, true>} |
||||
*/ |
||||
public function analyze(Node|array|null $body): array |
||||
{ |
||||
$captures = []; |
||||
$nonLocals = []; |
||||
$this->scan($body, $captures, $nonLocals); |
||||
return ['captures' => $captures, 'nonLocals' => $nonLocals]; |
||||
} |
||||
|
||||
/** |
||||
* @param array<string, true> $captures |
||||
* @param array<string, true> $nonLocals |
||||
*/ |
||||
private function scan(mixed $value, array &$captures, array &$nonLocals): void |
||||
{ |
||||
foreach (is_array($value) ? $value : [$value] as $node) { |
||||
if (!$node instanceof Node) { |
||||
continue; |
||||
} |
||||
|
||||
if ($node instanceof Expr\Closure) { |
||||
foreach ($node->uses as $use) { |
||||
if ($use->byRef |
||||
&& $use->var instanceof Expr\Variable |
||||
&& is_string($use->var->name) |
||||
) { |
||||
$captures[$use->var->name] = true; |
||||
} |
||||
} |
||||
// The Closure body owns another FunctionContext. |
||||
continue; |
||||
} |
||||
if ($node instanceof Expr\ArrowFunction |
||||
|| $node instanceof Stmt\Function_ |
||||
|| $node instanceof Stmt\ClassLike |
||||
|| $node instanceof FunctionLike |
||||
) { |
||||
continue; |
||||
} |
||||
|
||||
if ($node instanceof Stmt\Static_) { |
||||
foreach ($node->vars as $var) { |
||||
if ($var->var instanceof Expr\Variable && is_string($var->var->name)) { |
||||
$nonLocals[$var->var->name] = true; |
||||
} |
||||
} |
||||
} elseif ($node instanceof Stmt\Global_) { |
||||
foreach ($node->vars as $var) { |
||||
if ($var instanceof Expr\Variable && is_string($var->name)) { |
||||
$nonLocals[$var->name] = true; |
||||
} |
||||
} |
||||
} |
||||
|
||||
foreach ($node->getSubNodeNames() as $field) { |
||||
$this->scan($node->{$field}, $captures, $nonLocals); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,228 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
use Composer\InstalledVersions; |
||||
use RuntimeException; |
||||
|
||||
/** A Composer package that contributes C or C++ sources to a TypePHP native target. */ |
||||
final readonly class ComposerNativePackage |
||||
{ |
||||
/** |
||||
* @param list<string> $includeDirs |
||||
* @param list<string> $sources |
||||
*/ |
||||
private function __construct( |
||||
public string $name, |
||||
public string $installPath, |
||||
public string $kind, |
||||
public int $abi, |
||||
public int $cStandard, |
||||
public int $cxxStandard, |
||||
public array $includeDirs, |
||||
public array $sources, |
||||
public ?string $extensionName, |
||||
public ?string $extensionModuleEntry, |
||||
) { |
||||
} |
||||
|
||||
/** @return list<self> */ |
||||
public static function discover(): array |
||||
{ |
||||
$packages = []; |
||||
foreach (InstalledVersions::getInstalledPackages() as $package) { |
||||
$installPath = InstalledVersions::getInstallPath($package); |
||||
if (!is_string($installPath)) { |
||||
continue; |
||||
} |
||||
$manifestPath = $installPath . DIRECTORY_SEPARATOR . 'composer.json'; |
||||
$manifest = json_decode((string) @file_get_contents($manifestPath), true); |
||||
if (!is_array($manifest) |
||||
|| !is_array($manifest['extra']['typephp-native'] ?? null)) { |
||||
continue; |
||||
} |
||||
$packages[] = self::load($package); |
||||
} |
||||
|
||||
usort( |
||||
$packages, |
||||
static function (self $left, self $right): int { |
||||
$priority = ['runtime' => 0, 'bridge' => 1, 'library' => 2, 'extension' => 3]; |
||||
return [$priority[$left->kind], $left->name] |
||||
<=> [$priority[$right->kind], $right->name]; |
||||
}, |
||||
); |
||||
return $packages; |
||||
} |
||||
|
||||
public static function load(string $package): self |
||||
{ |
||||
if ($package === 'swoole/php-ext-standard') { |
||||
throw new RuntimeException( |
||||
'The standard extension is built into swoole/php-nano; remove obsolete package `swoole/php-ext-standard`' |
||||
); |
||||
} |
||||
$installPath = class_exists(InstalledVersions::class) && InstalledVersions::isInstalled($package) |
||||
? InstalledVersions::getInstallPath($package) |
||||
: null; |
||||
$root = is_string($installPath) ? realpath($installPath) : false; |
||||
if ($root === false) { |
||||
$root = self::resolveSiblingPackage($package); |
||||
} |
||||
if ($root === null) { |
||||
throw new RuntimeException( |
||||
"Native dependency `{$package}` is not installed; run `composer require {$package}`" |
||||
); |
||||
} |
||||
if (!is_dir($root)) { |
||||
throw new RuntimeException("Unable to resolve Composer install path for `{$package}`"); |
||||
} |
||||
|
||||
$manifestPath = $root . DIRECTORY_SEPARATOR . 'composer.json'; |
||||
$manifest = json_decode((string) file_get_contents($manifestPath), true); |
||||
$native = is_array($manifest) ? ($manifest['extra']['typephp-native'] ?? null) : null; |
||||
if (!is_array($native)) { |
||||
throw new RuntimeException( |
||||
"Composer package `{$package}` does not publish extra.typephp-native" |
||||
); |
||||
} |
||||
|
||||
$kind = $native['kind'] ?? 'library'; |
||||
if (!is_string($kind) || !in_array($kind, ['runtime', 'bridge', 'extension', 'library'], true)) { |
||||
throw new RuntimeException("Invalid native package kind in `{$package}`"); |
||||
} |
||||
|
||||
$extensionName = null; |
||||
$extensionModuleEntry = null; |
||||
if ($kind === 'extension') { |
||||
if (!str_starts_with($package, 'swoole/php-ext-')) { |
||||
throw new RuntimeException( |
||||
"Native extension package `{$package}` must use the `swoole/php-ext-*` naming convention" |
||||
); |
||||
} |
||||
$extension = $native['extension'] ?? null; |
||||
$extensionName = is_array($extension) ? ($extension['name'] ?? null) : null; |
||||
$extensionModuleEntry = is_array($extension) ? ($extension['module-entry'] ?? null) : null; |
||||
if (!is_string($extensionName) |
||||
|| preg_match('/^[a-z][a-z0-9_]*$/', $extensionName) !== 1 |
||||
|| !is_string($extensionModuleEntry) |
||||
|| preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $extensionModuleEntry) !== 1) { |
||||
throw new RuntimeException("Invalid native extension metadata in `{$package}`"); |
||||
} |
||||
$expectedPackage = 'swoole/php-ext-' . str_replace('_', '-', $extensionName); |
||||
if ($package !== $expectedPackage) { |
||||
throw new RuntimeException( |
||||
"Native extension `{$extensionName}` must use Composer package `{$expectedPackage}`" |
||||
); |
||||
} |
||||
$runtimeConstraint = $manifest['require']['swoole/php-nano'] ?? null; |
||||
if (!is_string($runtimeConstraint) || $runtimeConstraint === '') { |
||||
throw new RuntimeException( |
||||
"Native extension package `{$package}` must require `swoole/php-nano`" |
||||
); |
||||
} |
||||
} |
||||
|
||||
$abi = $native['abi'] ?? null; |
||||
$cStandard = $native['c-standard'] ?? null; |
||||
$cxxStandard = $native['cxx-standard'] ?? null; |
||||
if (!is_int($abi) || !is_int($cStandard) || !is_int($cxxStandard)) { |
||||
throw new RuntimeException("Invalid native ABI metadata in `{$package}`"); |
||||
} |
||||
if ($cStandard !== 11) { |
||||
throw new RuntimeException( |
||||
"Native dependency `{$package}` requires unsupported C{$cStandard}; expected C11" |
||||
); |
||||
} |
||||
if ($cxxStandard !== 17) { |
||||
throw new RuntimeException( |
||||
"Native dependency `{$package}` requires unsupported C++{$cxxStandard}; expected C++17" |
||||
); |
||||
} |
||||
|
||||
$sources = self::resolveEntries($root, $native['sources'] ?? null, false, $package); |
||||
foreach ($sources as $source) { |
||||
$extension = strtolower(pathinfo($source, PATHINFO_EXTENSION)); |
||||
if (!in_array($extension, ['c', 'cc', 'cpp', 'cxx'], true)) { |
||||
throw new RuntimeException( |
||||
"Native package `{$package}` contains unsupported source type: {$source}" |
||||
); |
||||
} |
||||
} |
||||
|
||||
return new self( |
||||
$package, |
||||
$root, |
||||
$kind, |
||||
$abi, |
||||
$cStandard, |
||||
$cxxStandard, |
||||
self::resolveEntries($root, $native['include-dirs'] ?? null, true, $package), |
||||
$sources, |
||||
$extensionName, |
||||
$extensionModuleEntry, |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Composer installs all three core packages as siblings below vendor/swoole. |
||||
* The same layout is used by the monorepo checkout, where a package may not |
||||
* yet be present in the checkout's generated InstalledVersions metadata. |
||||
*/ |
||||
private static function resolveSiblingPackage(string $package): ?string |
||||
{ |
||||
if (!defined('TYPEPHP_ROOT_PATH') || !str_starts_with($package, 'swoole/')) { |
||||
return null; |
||||
} |
||||
$candidate = realpath( |
||||
dirname(TYPEPHP_ROOT_PATH) . DIRECTORY_SEPARATOR . substr($package, strlen('swoole/')), |
||||
); |
||||
if ($candidate === false || !is_dir($candidate)) { |
||||
return null; |
||||
} |
||||
$manifest = json_decode((string) @file_get_contents($candidate . '/composer.json'), true); |
||||
return is_array($manifest) && ($manifest['name'] ?? null) === $package |
||||
? $candidate |
||||
: null; |
||||
} |
||||
|
||||
/** @return list<string> */ |
||||
private static function resolveEntries( |
||||
string $root, |
||||
mixed $entries, |
||||
bool $directories, |
||||
string $package, |
||||
): array { |
||||
if (!is_array($entries) || $entries === []) { |
||||
throw new RuntimeException("Native source metadata is empty in `{$package}`"); |
||||
} |
||||
|
||||
$resolved = []; |
||||
foreach ($entries as $entry) { |
||||
if (!is_string($entry) || $entry === '' || self::isAbsolutePath($entry)) { |
||||
throw new RuntimeException("Invalid native source path in `{$package}`"); |
||||
} |
||||
$path = realpath($root . DIRECTORY_SEPARATOR . $entry); |
||||
$validType = $path !== false && ($directories ? is_dir($path) : is_file($path)); |
||||
if (!$validType || !self::isInside($root, (string) $path)) { |
||||
throw new RuntimeException( |
||||
"Native source entry `{$entry}` is missing or escapes package `{$package}`" |
||||
); |
||||
} |
||||
$resolved[] = $path; |
||||
} |
||||
return $resolved; |
||||
} |
||||
|
||||
private static function isInside(string $root, string $path): bool |
||||
{ |
||||
return $path === $root |
||||
|| str_starts_with($path, rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR); |
||||
} |
||||
|
||||
private static function isAbsolutePath(string $path): bool |
||||
{ |
||||
return $path[0] === '/' || $path[0] === '\\' |
||||
|| preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1; |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
/** Selects the runtime build backend used by the --nano policy mode. */ |
||||
final class NanoBuildBackend |
||||
{ |
||||
/** Existing Windows host build linked through the PHP/PHPX import libraries. */ |
||||
public const WINDOWS_DLL = 'windows-dll'; |
||||
|
||||
/** Composer package manifests whose C/C++ sources are compiled into the program. */ |
||||
public const COMPOSER_SOURCES = 'composer-sources'; |
||||
|
||||
public static function forHost(string $osFamily): string |
||||
{ |
||||
return $osFamily === 'Windows' ? self::WINDOWS_DLL : self::COMPOSER_SOURCES; |
||||
} |
||||
|
||||
public static function composesRuntimeSources(string $osFamily): bool |
||||
{ |
||||
return self::forHost($osFamily) === self::COMPOSER_SOURCES; |
||||
} |
||||
} |
||||
@ -0,0 +1,113 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
use RuntimeException; |
||||
|
||||
/** Resolves the Composer-provided runtime sources used by a Nano build. */ |
||||
final class NanoSourceComposer |
||||
{ |
||||
/** |
||||
* @return array{ |
||||
* packages: list<ComposerNativePackage>, |
||||
* packageSources: list<string>, |
||||
* includeDirs: list<string>, |
||||
* registry: string |
||||
* } |
||||
*/ |
||||
public function compose(string $buildDir, string $targetName, bool $registerProject): array |
||||
{ |
||||
$runtime = ComposerNativePackage::load('swoole/php-nano'); |
||||
$phpx = ComposerNativePackage::load('swoole/phpx'); |
||||
if ($runtime->abi !== $phpx->abi) { |
||||
throw new RuntimeException( |
||||
"Native ABI mismatch: swoole/php-nano={$runtime->abi}, swoole/phpx={$phpx->abi}" |
||||
); |
||||
} |
||||
|
||||
$packagesByName = [ |
||||
$runtime->name => $runtime, |
||||
$phpx->name => $phpx, |
||||
]; |
||||
foreach (ComposerNativePackage::discover() as $package) { |
||||
$packagesByName[$package->name] = $package; |
||||
} |
||||
$packages = array_values($packagesByName); |
||||
|
||||
$packageSources = []; |
||||
$includeDirs = []; |
||||
foreach ($packages as $package) { |
||||
if ($package->abi !== $runtime->abi) { |
||||
throw new RuntimeException( |
||||
"Native ABI mismatch: {$package->name}={$package->abi}, " |
||||
. "swoole/php-nano={$runtime->abi}" |
||||
); |
||||
} |
||||
array_push($packageSources, ...$package->sources); |
||||
array_push($includeDirs, ...$package->includeDirs); |
||||
} |
||||
|
||||
return [ |
||||
'packages' => $packages, |
||||
'packageSources' => array_values(array_unique($packageSources)), |
||||
'includeDirs' => array_values(array_unique($includeDirs)), |
||||
'registry' => $this->writeExtensionRegistry( |
||||
$buildDir, |
||||
$targetName, |
||||
$packages, |
||||
$registerProject, |
||||
), |
||||
]; |
||||
} |
||||
|
||||
/** @param list<ComposerNativePackage> $packages */ |
||||
private function writeExtensionRegistry( |
||||
string $buildDir, |
||||
string $targetName, |
||||
array $packages, |
||||
bool $registerProject, |
||||
): string { |
||||
if (!is_dir($buildDir) && !mkdir($buildDir, 0777, true) && !is_dir($buildDir)) { |
||||
throw new RuntimeException("Unable to create Nano build directory: {$buildDir}"); |
||||
} |
||||
|
||||
$extensions = array_values(array_filter( |
||||
$packages, |
||||
static fn(ComposerNativePackage $package): bool => $package->kind === 'extension', |
||||
)); |
||||
$path = $buildDir . DIRECTORY_SEPARATOR . 'composer_extensions.cpp'; |
||||
$declarations = []; |
||||
$entries = []; |
||||
foreach ($extensions as $extension) { |
||||
$moduleEntry = $extension->extensionModuleEntry; |
||||
$declarations[] = "extern \"C\" zend_module_entry {$moduleEntry};"; |
||||
$entries[] = " &{$moduleEntry},"; |
||||
} |
||||
if ($registerProject) { |
||||
$namespace = 'typephp_project_' . $targetName; |
||||
$moduleEntry = 'typephp_' . $targetName . '_module_entry'; |
||||
$declarations[] = "namespace {$namespace} { extern zend_module_entry {$moduleEntry}; }"; |
||||
$entries[] = " &{$namespace}::{$moduleEntry},"; |
||||
} |
||||
|
||||
$count = count($entries); |
||||
$storageSize = max(1, $count); |
||||
$contents = "#include <php_nano_extension.h>\n\n" |
||||
. implode("\n", $declarations) . "\n\n" |
||||
. "extern \"C\" zend_result php_nano_startup_composer_extensions() {\n" |
||||
. " static zend_module_entry *extensions[{$storageSize}] = {\n" |
||||
. implode("\n", $entries) . "\n" |
||||
. " };\n" |
||||
. " return php_nano_startup_extensions(extensions, {$count});\n" |
||||
. "}\n\n" |
||||
. "extern \"C\" void php_nano_shutdown_composer_extensions() {\n" |
||||
. " php_nano_shutdown_extensions();\n" |
||||
. "}\n"; |
||||
if (!is_file($path) || file_get_contents($path) !== $contents) { |
||||
if (file_put_contents($path, $contents) === false) { |
||||
throw new RuntimeException("Unable to write Nano extension registry: {$path}"); |
||||
} |
||||
} |
||||
return $path; |
||||
} |
||||
} |
||||
@ -0,0 +1,105 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
use RuntimeException; |
||||
|
||||
/** Enforces PHP Nano's link-input and forbidden host-capability boundary. */ |
||||
final class NativeDependencyAuditor |
||||
{ |
||||
/** |
||||
* Reviewed native exceptions outside ISO C/C++ and POSIX.1-2008. |
||||
* POSIX functions do not need to be repeated in this list. |
||||
*/ |
||||
public const array NON_POSIX_HOST_FUNCTION_ALLOWLIST = [ |
||||
'flock', |
||||
]; |
||||
|
||||
/** @param list<string> $flags */ |
||||
public function assertLinkFlags(string $target, array $flags): void |
||||
{ |
||||
$allowedLibraries = $target === 'wasip2' |
||||
? ['-lsetjmp', '-lunwind'] |
||||
: []; |
||||
foreach ($flags as $flag) { |
||||
if (str_starts_with($flag, '-l') && !in_array($flag, $allowedLibraries, true)) { |
||||
throw new RuntimeException( |
||||
"PHP Nano target must not link external library `{$flag}`" |
||||
); |
||||
} |
||||
if (str_starts_with($flag, '-L')) { |
||||
throw new RuntimeException( |
||||
"PHP Nano target must not add external library path `{$flag}`" |
||||
); |
||||
} |
||||
} |
||||
} |
||||
|
||||
public function assertUndefinedSymbols(string $target, string $nmOutput): void |
||||
{ |
||||
$forbidden = []; |
||||
foreach (preg_split('/\R/', $nmOutput) ?: [] as $line) { |
||||
if (preg_match('/\bU\s+([^\s]+)\s*$/', trim($line), $match) !== 1) { |
||||
continue; |
||||
} |
||||
$symbol = preg_replace('/@.*$/', '', $match[1]) ?? $match[1]; |
||||
$symbol = ltrim($symbol, '_'); |
||||
if ($this->isForbiddenSymbol($symbol, $target)) { |
||||
$forbidden[$symbol] = true; |
||||
} |
||||
} |
||||
if ($forbidden !== []) { |
||||
$symbols = array_keys($forbidden); |
||||
sort($symbols); |
||||
throw new RuntimeException( |
||||
'PHP Nano artifact imports forbidden host capabilities: ' |
||||
. implode(', ', $symbols) |
||||
); |
||||
} |
||||
} |
||||
|
||||
private function isForbiddenSymbol(string $symbol, string $target): bool |
||||
{ |
||||
if ($target === 'wasip2') { |
||||
// LLVM nm prints Preview 1 imports using either their plain field |
||||
// name or a generated __imported_wasi_snapshot_preview1_* name. |
||||
$symbol = preg_replace( |
||||
'/^(?:imported_)?wasi_snapshot_preview1_/', |
||||
'', |
||||
$symbol, |
||||
) ?? $symbol; |
||||
/* C/C++ standard-library implementations may use clocks, entropy, |
||||
* environment, and WASI filesystem imports. Network remains out. */ |
||||
if ($symbol === 'clock_time_get' || $symbol === 'poll_oneoff' || $symbol === 'random_get') { |
||||
return false; |
||||
} |
||||
if (str_starts_with($symbol, 'sock_')) { |
||||
return true; |
||||
} |
||||
if ($symbol === 'proc_raise' || $symbol === 'sched_yield') { |
||||
return true; |
||||
} |
||||
} elseif (in_array($symbol, self::NON_POSIX_HOST_FUNCTION_ALLOWLIST, true)) { |
||||
return false; |
||||
} |
||||
|
||||
return preg_match( |
||||
'/^(?:' |
||||
. 'fork|vfork|exec(?:l|le|lp|lpe|v|ve|vp|vpe)?|system|popen|pclose|' |
||||
. 'posix_spawn(?:p)?|wait|waitpid|kill|' |
||||
. 'dlopen|dlsym|dlclose|dlerror|' |
||||
. 'socket|socketpair|connect|bind|listen|accept|accept4|send|sendto|sendmsg|' |
||||
. 'recv|recvfrom|recvmsg|shutdown|getsockopt|setsockopt|getpeername|getsockname|' |
||||
. 'getaddrinfo|freeaddrinfo|getnameinfo|gethostname|gethostbyaddr|gethostbyname|' |
||||
. 'gethostbynamel|inet_addr|inet_ntoa|inet_ntop|inet_pton|res_init|res_query|' |
||||
. 'pipe|pipe2|ioctl|select|pselect|poll|ppoll|' |
||||
. 'setenv|putenv|unsetenv|' |
||||
. 'openlog|closelog|syslog|' |
||||
. 'signal|sigaction|sigprocmask|pthread_sigmask|raise|alarm|setitimer|' |
||||
. 'mmap|munmap|mprotect|madvise|' |
||||
. 'syscall' |
||||
. ')$/', |
||||
$symbol, |
||||
) === 1; |
||||
} |
||||
} |
||||
@ -0,0 +1,481 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
use RuntimeException; |
||||
use TypePhp\Translator; |
||||
|
||||
/** |
||||
* Direct C and C++ source composer for a TypePHP native project. |
||||
* |
||||
* It intentionally does not generate or invoke CMake and does not create |
||||
* intermediate runtime/PHPX libraries. |
||||
*/ |
||||
final class NativeSourceProjectBuilder |
||||
{ |
||||
private bool $progressLineActive = false; |
||||
private int $progressLineWidth = 0; |
||||
|
||||
public function __construct(private readonly bool $lineProgress = false) |
||||
{ |
||||
} |
||||
|
||||
/** @return array{output: string, sourceCount: int, compiledCount: int} */ |
||||
public function build(NativeSourceProjectConfig $project): array |
||||
{ |
||||
if ($project->target === 'native' && PHP_OS_FAMILY === 'Windows') { |
||||
throw new RuntimeException( |
||||
'php-nano does not target Windows; use TypePHP --nano with the full PHP/PHPX DLL runtime' |
||||
); |
||||
} |
||||
|
||||
$this->writeProgress("Preparing Nano {$project->target} build: {$project->name}"); |
||||
|
||||
$compiler = $this->resolveExecutable($project->compiler); |
||||
$cCompiler = $this->resolveExecutable($project->cCompiler); |
||||
$this->ensureDirectory($project->buildDir); |
||||
$objectDir = $project->buildDir . DIRECTORY_SEPARATOR . 'objects'; |
||||
$this->ensureDirectory($objectDir); |
||||
|
||||
$generatedSources = []; |
||||
$generatedIncludeDir = null; |
||||
if ($project->phpSources !== []) { |
||||
$this->writeProgress( |
||||
'Generating C++ from ' . count($project->phpSources) . ' TypePHP source file(s)' |
||||
); |
||||
$generatedDir = $project->buildDir . DIRECTORY_SEPARATOR . 'generated'; |
||||
$translator = Translator::getInstance(); |
||||
$phpFiles = $translator->prepareNanoSources( |
||||
$project->phpSources, |
||||
$this->projectSymbolName($project), |
||||
$generatedDir, |
||||
$project->target === 'wasip2', |
||||
); |
||||
$generatedSources = $translator->convert($phpFiles); |
||||
$generatedIncludeDir = $generatedDir . DIRECTORY_SEPARATOR . 'include'; |
||||
} |
||||
|
||||
$composition = (new NanoSourceComposer())->compose( |
||||
$project->buildDir, |
||||
$this->projectSymbolName($project), |
||||
$project->phpSources !== [], |
||||
); |
||||
$packages = $composition['packages']; |
||||
$extensionRegistry = $composition['registry']; |
||||
$sources = [...$project->sources, ...$generatedSources]; |
||||
$includeDirs = []; |
||||
if ($generatedIncludeDir !== null) { |
||||
$includeDirs[] = $generatedIncludeDir; |
||||
} |
||||
array_push($sources, ...$composition['packageSources']); |
||||
array_push($includeDirs, ...$composition['includeDirs']); |
||||
$sources[] = $extensionRegistry; |
||||
$sources = array_values(array_unique($sources)); |
||||
$includeDirs = array_values(array_unique($includeDirs)); |
||||
$sourceCount = count($sources); |
||||
$this->writeProgress( |
||||
"Resolved {$sourceCount} C/C++ source file(s) from " . count($packages) . ' Composer package(s)' |
||||
); |
||||
$strictSources = array_fill_keys( |
||||
[...$project->sources, ...$generatedSources, $extensionRegistry], |
||||
true, |
||||
); |
||||
|
||||
$objects = []; |
||||
$compiledCount = 0; |
||||
foreach ($sources as $sourceIndex => $source) { |
||||
$isCSource = strtolower(pathinfo($source, PATHINFO_EXTENSION)) === 'c'; |
||||
$sourceCompiler = $isCSource ? $cCompiler : $compiler; |
||||
$object = $objectDir . DIRECTORY_SEPARATOR |
||||
. pathinfo($source, PATHINFO_FILENAME) . '-' . substr(sha1($source), 0, 12) . '.o'; |
||||
$dependencyFile = $object . '.d'; |
||||
$signatureFile = $object . '.command'; |
||||
$command = [ |
||||
$sourceCompiler, |
||||
$isCSource ? '-std=c11' : '-std=c++17', |
||||
...$this->compileModeFlags($project), |
||||
...(isset($strictSources[$source]) ? ['-Wall', '-Wextra', '-Wpedantic'] : []), |
||||
'-DTYPEPHP_NATIVE=1', |
||||
'-DPHP_NANO=1', |
||||
'-DPHPX_NANO=1', |
||||
'-D_POSIX_C_SOURCE=200809L', |
||||
'-ffunction-sections', |
||||
'-fdata-sections', |
||||
// Package headers are passed with -isystem to suppress upstream |
||||
// warnings, but they are still build inputs. -MD keeps them in |
||||
// the depfile; -MMD would silently leave stale Nano/PHPX objects. |
||||
'-MD', |
||||
'-MF', |
||||
$dependencyFile, |
||||
]; |
||||
foreach ($includeDirs as $includeDir) { |
||||
$command[] = '-isystem'; |
||||
$command[] = $includeDir; |
||||
} |
||||
array_push($command, '-c', $source, '-o', $object); |
||||
$signature = hash('sha256', implode("\0", $command)); |
||||
if ($this->needsCompile($object, $dependencyFile, $signatureFile, $signature)) { |
||||
$this->writeCompileProgress( |
||||
$sourceIndex + 1, |
||||
$sourceCount, |
||||
$this->sourceLabel($source, $project, $packages), |
||||
); |
||||
$this->run($command, dirname($project->file)); |
||||
$this->writeFile($signatureFile, $signature . "\n"); |
||||
++$compiledCount; |
||||
} |
||||
$objects[] = $object; |
||||
} |
||||
$this->finishProgressLine(); |
||||
$cachedCount = $sourceCount - $compiledCount; |
||||
$this->writeProgress("Compilation complete: {$compiledCount} compiled, {$cachedCount} cached"); |
||||
|
||||
$this->ensureDirectory(dirname($project->output)); |
||||
$linkCommand = [ |
||||
$compiler, |
||||
...$objects, |
||||
...$this->linkModeFlags($project), |
||||
'-o', |
||||
$project->output, |
||||
]; |
||||
$auditor = new NativeDependencyAuditor(); |
||||
$auditor->assertLinkFlags($project->target, $this->linkModeFlags($project)); |
||||
// Link metadata is an intermediate build artifact. Keep it out of the |
||||
// invocation/output directory when the executable defaults to ./name. |
||||
$linkSignatureFile = $project->buildDir . DIRECTORY_SEPARATOR |
||||
. 'link-' . substr(sha1($project->output), 0, 12) . '.command'; |
||||
$linkSignature = hash('sha256', implode("\0", $linkCommand)); |
||||
if ($compiledCount !== 0 |
||||
|| $this->needsLink($project->output, $objects, $linkSignatureFile, $linkSignature)) { |
||||
$this->writeProgress('Linking: ' . $project->output); |
||||
$this->run($linkCommand, dirname($project->file)); |
||||
$this->writeFile($linkSignatureFile, $linkSignature . "\n"); |
||||
} else { |
||||
$this->writeProgress('Link is up to date: ' . $project->output); |
||||
} |
||||
$this->writeProgress('Auditing native dependencies'); |
||||
$this->auditArtifact($project, $objects, $compiler, $auditor); |
||||
|
||||
return [ |
||||
'output' => $project->output, |
||||
'sourceCount' => $sourceCount, |
||||
'compiledCount' => $compiledCount, |
||||
]; |
||||
} |
||||
|
||||
/** @param list<ComposerNativePackage> $packages */ |
||||
private function sourceLabel( |
||||
string $source, |
||||
NativeSourceProjectConfig $project, |
||||
array $packages, |
||||
): string { |
||||
foreach ($packages as $package) { |
||||
$prefix = rtrim($package->installPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; |
||||
if (str_starts_with($source, $prefix)) { |
||||
return $package->name . '/' . str_replace( |
||||
DIRECTORY_SEPARATOR, |
||||
'/', |
||||
substr($source, strlen($prefix)), |
||||
); |
||||
} |
||||
} |
||||
$buildPrefix = rtrim($project->buildDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; |
||||
if (str_starts_with($source, $buildPrefix)) { |
||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($source, strlen($buildPrefix))); |
||||
return preg_replace('#/+#', '/', $relative) ?? $relative; |
||||
} |
||||
return $source; |
||||
} |
||||
|
||||
private function writeCompileProgress(int $current, int $total, string $source): void |
||||
{ |
||||
$message = "Compiling [{$current}/{$total}] {$source}"; |
||||
if ($this->lineProgress || !function_exists('stream_isatty') || !stream_isatty(STDOUT)) { |
||||
$this->writeProgress($message); |
||||
return; |
||||
} |
||||
|
||||
$padding = max(0, $this->progressLineWidth - strlen($message)); |
||||
fwrite(STDOUT, "\r{$message}" . str_repeat(' ', $padding)); |
||||
fflush(STDOUT); |
||||
$this->progressLineActive = true; |
||||
$this->progressLineWidth = strlen($message); |
||||
} |
||||
|
||||
private function writeProgress(string $message): void |
||||
{ |
||||
$this->finishProgressLine(); |
||||
fwrite(STDOUT, $message . PHP_EOL); |
||||
fflush(STDOUT); |
||||
} |
||||
|
||||
private function finishProgressLine(): void |
||||
{ |
||||
if (!$this->progressLineActive) { |
||||
return; |
||||
} |
||||
fwrite(STDOUT, PHP_EOL); |
||||
fflush(STDOUT); |
||||
$this->progressLineActive = false; |
||||
$this->progressLineWidth = 0; |
||||
} |
||||
|
||||
private function projectSymbolName(NativeSourceProjectConfig $project): string |
||||
{ |
||||
return str_replace(['-', '.'], '_', $project->name); |
||||
} |
||||
|
||||
private function needsCompile( |
||||
string $object, |
||||
string $dependencyFile, |
||||
string $signatureFile, |
||||
string $signature, |
||||
): bool { |
||||
if (!is_file($object) || !is_file($dependencyFile) |
||||
|| trim((string) @file_get_contents($signatureFile)) !== $signature) { |
||||
return true; |
||||
} |
||||
|
||||
$objectTime = filemtime($object); |
||||
if ($objectTime === false) { |
||||
return true; |
||||
} |
||||
foreach ($this->readDependencies($dependencyFile) as $dependency) { |
||||
$dependencyTime = filemtime($dependency); |
||||
if ($dependencyTime === false || $dependencyTime > $objectTime) { |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
/** @param list<string> $objects */ |
||||
private function needsLink( |
||||
string $output, |
||||
array $objects, |
||||
string $signatureFile, |
||||
string $signature, |
||||
): bool { |
||||
if (!is_file($output) |
||||
|| trim((string) @file_get_contents($signatureFile)) !== $signature) { |
||||
return true; |
||||
} |
||||
$outputTime = filemtime($output); |
||||
if ($outputTime === false) { |
||||
return true; |
||||
} |
||||
foreach ($objects as $object) { |
||||
$objectTime = filemtime($object); |
||||
if ($objectTime === false || $objectTime > $outputTime) { |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
/** @return list<string> */ |
||||
private function readDependencies(string $dependencyFile): array |
||||
{ |
||||
$contents = file_get_contents($dependencyFile); |
||||
if (!is_string($contents)) { |
||||
return []; |
||||
} |
||||
$contents = preg_replace('/\\\\\r?\n/', ' ', $contents) ?? ''; |
||||
$separator = strpos($contents, ': '); |
||||
if ($separator === false) { |
||||
return []; |
||||
} |
||||
$dependencies = trim(substr($contents, $separator + 2)); |
||||
if ($dependencies === '') { |
||||
return []; |
||||
} |
||||
$entries = preg_split('/(?<!\\\\)\s+/', $dependencies) ?: []; |
||||
return array_values(array_filter(array_map( |
||||
static fn(string $entry): string => str_replace(['\\ ', '\\\\'], [' ', '\\'], $entry), |
||||
$entries, |
||||
), static fn(string $entry): bool => $entry !== '')); |
||||
} |
||||
|
||||
private function writeFile(string $path, string $contents): void |
||||
{ |
||||
if (file_put_contents($path, $contents) === false) { |
||||
throw new RuntimeException("Unable to write native build metadata: {$path}"); |
||||
} |
||||
} |
||||
|
||||
public function runOutput(NativeSourceProjectConfig $project): never |
||||
{ |
||||
$command = $project->target === 'wasip2' |
||||
? [$this->resolveExecutable('wasmtime'), $project->output] |
||||
: [$project->output]; |
||||
$status = $this->run($command, dirname($project->file), false); |
||||
exit($status); |
||||
} |
||||
|
||||
/** @return list<string> */ |
||||
private function compileModeFlags(NativeSourceProjectConfig $project): array |
||||
{ |
||||
$flags = $project->buildType === 'debug' |
||||
? ['-O0', '-g'] |
||||
: ['-O2', '-DNDEBUG']; |
||||
if ($project->target === 'wasip2') { |
||||
array_push( |
||||
$flags, |
||||
'-fwasm-exceptions', |
||||
'-DZEND_MM_ERROR=0', |
||||
'-mllvm', |
||||
'-wasm-enable-sjlj', |
||||
'-mllvm', |
||||
'-wasm-use-legacy-eh=false', |
||||
); |
||||
} |
||||
return $flags; |
||||
} |
||||
|
||||
/** @return list<string> */ |
||||
private function linkModeFlags(NativeSourceProjectConfig $project): array |
||||
{ |
||||
if ($project->target === 'wasip2') { |
||||
// WASI SDK ships libunwind as the exception ABI companion of its |
||||
// libc++; it is a toolchain runtime, not an application dependency. |
||||
return [ |
||||
'-fwasm-exceptions', |
||||
'-lsetjmp', |
||||
'-lunwind', |
||||
'-Wl,--gc-sections', |
||||
]; |
||||
} |
||||
return PHP_OS_FAMILY === 'Darwin' |
||||
? ['-Wl,-dead_strip'] |
||||
: (PHP_OS_FAMILY === 'Windows' ? [] : ['-Wl,--gc-sections']); |
||||
} |
||||
|
||||
private function resolveExecutable(string $command): string |
||||
{ |
||||
if (str_contains($command, '/') || str_contains($command, '\\')) { |
||||
$path = realpath($command); |
||||
if ($path === false || !is_executable($path)) { |
||||
throw new RuntimeException("Native build tool is not executable: {$command}"); |
||||
} |
||||
return $path; |
||||
} |
||||
|
||||
$path = getenv('PATH'); |
||||
foreach (explode(PATH_SEPARATOR, is_string($path) ? $path : '') as $directory) { |
||||
if ($directory === '') { |
||||
continue; |
||||
} |
||||
$candidate = $directory . DIRECTORY_SEPARATOR . $command; |
||||
if (is_file($candidate) && is_executable($candidate)) { |
||||
return $candidate; |
||||
} |
||||
} |
||||
throw new RuntimeException("Native build tool was not found on PATH: {$command}"); |
||||
} |
||||
|
||||
/** @param list<string> $objects */ |
||||
private function auditArtifact( |
||||
NativeSourceProjectConfig $project, |
||||
array $objects, |
||||
string $compiler, |
||||
NativeDependencyAuditor $auditor, |
||||
): void { |
||||
if ($project->target === 'wasip2') { |
||||
$nm = dirname($compiler) . DIRECTORY_SEPARATOR . 'llvm-nm'; |
||||
if (!is_executable($nm)) { |
||||
$nm = $this->resolveExecutable('llvm-nm'); |
||||
} |
||||
$auditor->assertUndefinedSymbols( |
||||
$project->target, |
||||
$this->runCapture([$nm, '--undefined-only', ...$objects], dirname($project->file)), |
||||
); |
||||
|
||||
$auditCore = $project->buildDir . DIRECTORY_SEPARATOR . '.native-audit-core.wasm'; |
||||
try { |
||||
$this->run([ |
||||
$compiler, |
||||
...$objects, |
||||
...$this->linkModeFlags($project), |
||||
'-Wl,--wasi-adapter=none', |
||||
'-Wl,--skip-wit-component', |
||||
'-o', |
||||
$auditCore, |
||||
], dirname($project->file)); |
||||
$auditor->assertUndefinedSymbols( |
||||
$project->target, |
||||
$this->runCapture([$nm, '--undefined-only', $auditCore], dirname($project->file)), |
||||
); |
||||
} finally { |
||||
if (is_file($auditCore)) { |
||||
unlink($auditCore); |
||||
} |
||||
} |
||||
return; |
||||
} |
||||
|
||||
$nm = $this->resolveExecutable('nm'); |
||||
$auditor->assertUndefinedSymbols( |
||||
$project->target, |
||||
$this->runCapture([$nm, '-u', ...$objects], dirname($project->file)), |
||||
); |
||||
$auditor->assertUndefinedSymbols( |
||||
$project->target, |
||||
$this->runCapture( |
||||
[$nm, '-u', $project->output], |
||||
dirname($project->file), |
||||
), |
||||
); |
||||
} |
||||
|
||||
private function ensureDirectory(string $directory): void |
||||
{ |
||||
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { |
||||
throw new RuntimeException("Unable to create native build directory: {$directory}"); |
||||
} |
||||
} |
||||
|
||||
/** @param list<string> $command */ |
||||
private function run(array $command, string $workingDirectory, bool $throw = true): int |
||||
{ |
||||
$process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, $workingDirectory); |
||||
if (!is_resource($process)) { |
||||
throw new RuntimeException("Unable to start native build tool: {$command[0]}"); |
||||
} |
||||
$status = proc_close($process); |
||||
if ($throw && $status !== 0) { |
||||
$this->finishProgressLine(); |
||||
throw new RuntimeException( |
||||
'Native build command failed with status ' . $status . ': ' |
||||
. implode(' ', array_map(escapeshellarg(...), $command)) |
||||
); |
||||
} |
||||
return $status; |
||||
} |
||||
|
||||
/** @param list<string> $command */ |
||||
private function runCapture(array $command, string $workingDirectory): string |
||||
{ |
||||
$process = proc_open( |
||||
$command, |
||||
[STDIN, ['pipe', 'w'], ['pipe', 'w']], |
||||
$pipes, |
||||
$workingDirectory, |
||||
); |
||||
if (!is_resource($process)) { |
||||
throw new RuntimeException("Unable to start native audit tool: {$command[0]}"); |
||||
} |
||||
$stdout = stream_get_contents($pipes[1]); |
||||
$stderr = stream_get_contents($pipes[2]); |
||||
fclose($pipes[1]); |
||||
fclose($pipes[2]); |
||||
$status = proc_close($process); |
||||
if ($status !== 0) { |
||||
throw new RuntimeException( |
||||
'Native audit command failed with status ' . $status . ': ' |
||||
. implode(' ', array_map(escapeshellarg(...), $command)) |
||||
. ($stderr === '' ? '' : "\n{$stderr}") |
||||
); |
||||
} |
||||
return (string) $stdout; |
||||
} |
||||
} |
||||
@ -0,0 +1,156 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
use DOMDocument; |
||||
use DOMElement; |
||||
use RuntimeException; |
||||
|
||||
/** XML configuration for a source-composition native project. */ |
||||
final readonly class NativeSourceProjectConfig |
||||
{ |
||||
/** |
||||
* @param list<string> $phpSources |
||||
* @param list<string> $sources Native C and C++ sources. |
||||
*/ |
||||
private function __construct( |
||||
public string $file, |
||||
public string $name, |
||||
public string $target, |
||||
public string $buildType, |
||||
public string $compiler, |
||||
public string $cCompiler, |
||||
public string $buildDir, |
||||
public string $output, |
||||
public array $phpSources, |
||||
public array $sources, |
||||
) { |
||||
} |
||||
|
||||
public static function isNativeProject(string $path): bool |
||||
{ |
||||
if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'xml' || !is_file($path)) { |
||||
return false; |
||||
} |
||||
$document = new DOMDocument(); |
||||
if (!@$document->load($path, LIBXML_NONET | LIBXML_NOBLANKS)) { |
||||
return false; |
||||
} |
||||
return $document->documentElement?->tagName === 'project' |
||||
&& strtolower($document->documentElement->getAttribute('mode')) === 'native'; |
||||
} |
||||
|
||||
public static function load(string $path, ?string $buildDirOverride = null): self |
||||
{ |
||||
$file = realpath($path); |
||||
if ($file === false) { |
||||
throw new RuntimeException("Native project does not exist: {$path}"); |
||||
} |
||||
|
||||
$document = new DOMDocument(); |
||||
if (!@$document->load($file, LIBXML_NONET | LIBXML_NOBLANKS)) { |
||||
throw new RuntimeException("Unable to parse native project XML: {$file}"); |
||||
} |
||||
$root = $document->documentElement; |
||||
if (!$root instanceof DOMElement || $root->tagName !== 'project' |
||||
|| strtolower($root->getAttribute('mode')) !== 'native') { |
||||
throw new RuntimeException('Native project root must be `<project mode="native">`'); |
||||
} |
||||
|
||||
$projectDir = dirname($file); |
||||
$name = trim($root->getAttribute('name')); |
||||
if ($name === '') { |
||||
$name = basename($projectDir); |
||||
} |
||||
if (preg_match('/^[A-Za-z0-9_.-]+$/', $name) !== 1) { |
||||
throw new RuntimeException("Invalid native project name: {$name}"); |
||||
} |
||||
|
||||
$target = strtolower(self::elementText($root, 'target') ?? 'native'); |
||||
if (!in_array($target, ['native', 'wasip2'], true)) { |
||||
throw new RuntimeException('Native target must be `native` or `wasip2`'); |
||||
} |
||||
$buildType = strtolower(self::elementText($root, 'build-type') ?? 'release'); |
||||
if (!in_array($buildType, ['release', 'debug'], true)) { |
||||
throw new RuntimeException('Native build-type must be `release` or `debug`'); |
||||
} |
||||
|
||||
$compiler = self::elementText($root, 'compiler') |
||||
?? ($target === 'wasip2' ? 'wasm32-wasip2-clang++' : 'c++'); |
||||
if ($compiler === '' || preg_match('/[\x00-\x1f]/', $compiler)) { |
||||
throw new RuntimeException('Invalid native compiler command'); |
||||
} |
||||
$cCompiler = self::elementText($root, 'c-compiler') |
||||
?? ($target === 'wasip2' ? 'wasm32-wasip2-clang' : 'cc'); |
||||
if ($cCompiler === '' || preg_match('/[\x00-\x1f]/', $cCompiler)) { |
||||
throw new RuntimeException('Invalid native C compiler command'); |
||||
} |
||||
|
||||
$configuredBuildDir = $buildDirOverride |
||||
?? self::elementText($root, 'build-dir') |
||||
?? 'build/native-' . $target; |
||||
$buildDir = self::absolutePath($projectDir, $configuredBuildDir); |
||||
$outputName = self::elementText($root, 'output') |
||||
?? ($target === 'wasip2' ? $name . '.wasm' : $name); |
||||
$output = self::absolutePath($buildDir, $outputName); |
||||
|
||||
$phpSources = []; |
||||
$sources = []; |
||||
foreach ($root->getElementsByTagName('source') as $sourceNode) { |
||||
$source = trim($sourceNode->textContent); |
||||
if ($source === '') { |
||||
throw new RuntimeException('Native project contains an empty `<source>`'); |
||||
} |
||||
$resolved = realpath(self::absolutePath($projectDir, $source)); |
||||
if ($resolved === false || !is_file($resolved)) { |
||||
throw new RuntimeException("Native project source does not exist: {$source}"); |
||||
} |
||||
$extension = strtolower(pathinfo($resolved, PATHINFO_EXTENSION)); |
||||
if ($extension === 'php') { |
||||
$phpSources[] = $resolved; |
||||
continue; |
||||
} |
||||
if (!in_array($extension, ['c', 'cc', 'cpp', 'cxx'], true)) { |
||||
throw new RuntimeException( |
||||
"Native project source must be PHP, C, or C++: {$source}" |
||||
); |
||||
} |
||||
$sources[] = $resolved; |
||||
} |
||||
if ($phpSources === [] && $sources === []) { |
||||
throw new RuntimeException('Native project must contain at least one `<source>`'); |
||||
} |
||||
|
||||
return new self( |
||||
$file, |
||||
$name, |
||||
$target, |
||||
$buildType, |
||||
$compiler, |
||||
$cCompiler, |
||||
$buildDir, |
||||
$output, |
||||
array_values(array_unique($phpSources)), |
||||
array_values(array_unique($sources)), |
||||
); |
||||
} |
||||
|
||||
private static function elementText(DOMElement $root, string $name): ?string |
||||
{ |
||||
$nodes = $root->getElementsByTagName($name); |
||||
if ($nodes->length === 0) { |
||||
return null; |
||||
} |
||||
$value = trim($nodes->item(0)?->textContent ?? ''); |
||||
return $value === '' ? null : $value; |
||||
} |
||||
|
||||
private static function absolutePath(string $base, string $path): string |
||||
{ |
||||
if ($path[0] === '/' || $path[0] === '\\' |
||||
|| preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1) { |
||||
return $path; |
||||
} |
||||
return $base . DIRECTORY_SEPARATOR . $path; |
||||
} |
||||
} |
||||
@ -0,0 +1,72 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Transform; |
||||
|
||||
use PhpParser\Node; |
||||
use PhpParser\NodeVisitorAbstract; |
||||
|
||||
/** Reject syntax whose semantics require the runtime PHP compiler or Zend VM. */ |
||||
final class NanoSyntaxValidationVisitor extends NodeVisitorAbstract |
||||
{ |
||||
private const array UNSUPPORTED_RUNTIME_CLASSES = [ |
||||
'Fiber' => true, |
||||
'Generator' => true, |
||||
'ReflectionFiber' => true, |
||||
'ReflectionGenerator' => true, |
||||
]; |
||||
|
||||
/** @param callable(Node, string): never $fatal */ |
||||
public function __construct( |
||||
private readonly mixed $fatal, |
||||
private readonly bool $phpNanoRuntime = true, |
||||
) { |
||||
} |
||||
|
||||
public function enterNode(Node $node): ?Node |
||||
{ |
||||
if ($this->phpNanoRuntime && $node instanceof Node\Name) { |
||||
$resolved = $node->getAttribute('resolvedName'); |
||||
$className = $resolved instanceof Node\Name |
||||
? $resolved->toString() |
||||
: ($node instanceof Node\Name\FullyQualified ? $node->toString() : null); |
||||
if ($className !== null && isset(self::UNSUPPORTED_RUNTIME_CLASSES[$className])) { |
||||
($this->fatal)( |
||||
$node, |
||||
"Class `{$className}` is not supported in nano mode because it requires Generator/Fiber runtime support", |
||||
); |
||||
} |
||||
} |
||||
|
||||
if ($node instanceof Node\Expr\Eval_) { |
||||
($this->fatal)($node, '`eval` is not supported in nano mode'); |
||||
} |
||||
|
||||
if ($node instanceof Node\Expr\Include_) { |
||||
$keyword = match ($node->type) { |
||||
Node\Expr\Include_::TYPE_INCLUDE => 'include', |
||||
Node\Expr\Include_::TYPE_INCLUDE_ONCE => 'include_once', |
||||
Node\Expr\Include_::TYPE_REQUIRE => 'require', |
||||
Node\Expr\Include_::TYPE_REQUIRE_ONCE => 'require_once', |
||||
}; |
||||
($this->fatal)($node, "`{$keyword}` is not supported in nano mode"); |
||||
} |
||||
|
||||
if ($node instanceof Node\Expr\ShellExec) { |
||||
($this->fatal)($node, 'Backtick shell execution is not supported in nano mode'); |
||||
} |
||||
|
||||
if ($node instanceof Node\Stmt\Class_ && $node->name === null) { |
||||
($this->fatal)($node, 'Anonymous classes are not supported in nano mode'); |
||||
} |
||||
|
||||
if ($this->phpNanoRuntime |
||||
&& ($node instanceof Node\Expr\Yield_ || $node instanceof Node\Expr\YieldFrom)) { |
||||
($this->fatal)( |
||||
$node, |
||||
'Fiber and Generator are not supported in nano mode because C++17 has no standard stack-switching API', |
||||
); |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
} |
||||
@ -0,0 +1,69 @@ |
||||
--TEST-- |
||||
Closure reference captures automatically degrade inferred locals to var storage |
||||
--FILE-- |
||||
<?php |
||||
function make_counter(): Closure |
||||
{ |
||||
$count = 0; |
||||
return static function () use (&$count): int { |
||||
return ++$count; |
||||
}; |
||||
} |
||||
|
||||
function replace_parameter(string $value): Closure |
||||
{ |
||||
return static function () use (&$value): array { |
||||
$value = ['parameter']; |
||||
return $value; |
||||
}; |
||||
} |
||||
|
||||
final class ParameterCapture |
||||
{ |
||||
public function replace(array $value): Closure |
||||
{ |
||||
return function () use (&$value): string { |
||||
$value = 'method'; |
||||
return $value; |
||||
}; |
||||
} |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
$counter = make_counter(); |
||||
var_dump($counter()); |
||||
var_dump($counter()); |
||||
|
||||
$value = 'before'; |
||||
$change = static function () use (&$value): void { |
||||
$value = ['after']; |
||||
}; |
||||
$change(); |
||||
var_dump($value); |
||||
|
||||
$object = new stdClass(); |
||||
$replaceObject = static function () use (&$object): void { |
||||
$object = 'replaced'; |
||||
}; |
||||
$replaceObject(); |
||||
var_dump($object); |
||||
|
||||
$replaceParameter = replace_parameter('before'); |
||||
var_dump($replaceParameter()); |
||||
var_dump((new ParameterCapture())->replace([])()); |
||||
} |
||||
?> |
||||
--EXPECT-- |
||||
int(1) |
||||
int(2) |
||||
array(1) { |
||||
[0]=> |
||||
string(5) "after" |
||||
} |
||||
string(8) "replaced" |
||||
array(1) { |
||||
[0]=> |
||||
string(9) "parameter" |
||||
} |
||||
string(6) "method" |
||||
@ -0,0 +1,84 @@ |
||||
#!/usr/bin/env bash |
||||
|
||||
set -euo pipefail |
||||
|
||||
fatal_error() { |
||||
printf 'Fatal error: %s\n' "$1" >&2 |
||||
exit 1 |
||||
} |
||||
|
||||
if [[ $# -ne 4 ]]; then |
||||
echo "Usage: $0 <program.php> <output.wasm|-> <phpx-dir> <tpc-executable>" >&2 |
||||
exit 1 |
||||
fi |
||||
|
||||
caller_dir=${PWD} |
||||
input=$1 |
||||
typephp_compiler=$4 |
||||
wasm_mode=${TYPEPHP_WASM_MODE:-command} |
||||
|
||||
if [[ "${wasm_mode}" != command ]]; then |
||||
fatal_error "--nano --wasm currently supports command/binary projects only" |
||||
fi |
||||
|
||||
if [[ "${input}" != /* ]]; then |
||||
input=${caller_dir}/${input} |
||||
fi |
||||
input=$(realpath "${input}") |
||||
|
||||
stem=$(basename "${input}" .php) |
||||
stem=${stem//[^a-zA-Z0-9_-]/_} |
||||
build_root=${TYPEPHP_WASM_PROGRAM_BUILD_DIR:-${caller_dir}/build} |
||||
mkdir -p "${build_root}" |
||||
build_root=$(cd "${build_root}" && pwd) |
||||
|
||||
if [[ $2 != - ]]; then |
||||
output=$2 |
||||
if [[ "${output}" != /* ]]; then |
||||
output=${caller_dir}/${output} |
||||
fi |
||||
else |
||||
output=${caller_dir}/${stem}.wasm |
||||
fi |
||||
mkdir -p "$(dirname "${output}")" |
||||
|
||||
# Keep code generation, parallel scheduling, progress, caching, and linking in |
||||
# the normal TypePHP compiler. Nano changes only the runtime sources and link |
||||
# inputs; this script only orchestrates WASI component/browser packaging. |
||||
TYPEPHP_WASM_INTERNAL_COMPILE=1 "${typephp_compiler}" "${input}" \ |
||||
--nano \ |
||||
--target-platform wasm32-wasip2 \ |
||||
--build-dir "${build_root}" \ |
||||
-O2 \ |
||||
-o "${output}" |
||||
|
||||
echo "Built TypePHP/Nano WASI program: ${output}" |
||||
|
||||
if [[ "${TYPEPHP_WASM_BROWSER:-0}" == 1 ]]; then |
||||
jco_bin=${TYPEPHP_JCO:-jco} |
||||
browser_dir=${TYPEPHP_WASM_BROWSER_DIR:-${output%.wasm}.browser} |
||||
mkdir -p "${browser_dir}" |
||||
jco_flags=() |
||||
if "${jco_bin}" transpile --help 2>&1 | grep -q -- '--bindgen-enable-wasm-exnref'; then |
||||
jco_flags+=(--bindgen-enable-wasm-exnref) |
||||
fi |
||||
if ! "${jco_bin}" transpile --help 2>&1 | grep -q -- '--async-wasi-imports'; then |
||||
fatal_error "Jco does not support JSPI-backed asynchronous WASI imports; upgrade Jco" |
||||
fi |
||||
jco_flags+=(--async-mode jspi --async-wasi-imports --async-wasi-exports) |
||||
"${jco_bin}" transpile "${output}" \ |
||||
-o "${browser_dir}" \ |
||||
--name program \ |
||||
--no-nodejs-compat \ |
||||
--no-namespaced-exports \ |
||||
--instantiation async \ |
||||
--base64-cutoff=0 \ |
||||
"${jco_flags[@]}" |
||||
echo "Built TypePHP/Nano WASI browser module: ${browser_dir}" |
||||
fi |
||||
|
||||
if [[ "${TYPEPHP_WASM_RUN:-0}" == 1 ]]; then |
||||
wasmtime_bin=${TYPEPHP_WASMTIME:-wasmtime} |
||||
XDG_CACHE_HOME=${XDG_CACHE_HOME:-/tmp/typephp-wasmtime-cache} \ |
||||
"${wasmtime_bin}" "${output}" |
||||
fi |
||||
Loading…
Reference in new issue