- 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