diff --git a/README-CN.md b/README-CN.md index 96f866f8..730707a8 100644 --- a/README-CN.md +++ b/README-CN.md @@ -228,6 +228,38 @@ string(16) "Linux ..." > `main(int $argc, array $argv)` 以接收命令行参数,且必须返回 `void`。全局作用域 > 不允许可执行语句;可执行代码必须位于函数或方法内。 +### 无 VM 的 Nano 原生程序 + +使用 `--nano` 可将单个 PHP 源文件与 PHP Nano、PHPX 源码整体编译。产物不链接 +`libphp`,也不包含 Zend opcode 解释器: + +```bash +./bin/tpc.php --nano examples/hello.php +./hello +``` + +默认可执行文件生成在执行 `tpc` 时的当前目录;普通模式与 Nano 模式共用 +`build` 目录保存生成代码、目标文件等中间产物。可使用 `-o` 显式修改输出路径。 + +PHP 与 Composer 仅用于编译期。在 Linux、macOS、iOS、Android 上,生成的程序 +使用静态选定的 Nano 运行时及仅文件模式的 stream。Native Nano 可使用 C11、 +C++17 与 POSIX.1-2008,但依然不提供 socket、DNS、网络、远程 stream、动态 PHP +加载及进程执行能力。WASI 是更小的能力子集,直接调用目标不支持的 API 会在 +编译期报错。 + +所有平台的 `--nano` 都会拒绝 `eval`、`include`、`include_once`、`require`、 +`require_once` 等 VM 入口以及匿名类。 + +Windows 的差异在于构建后端:即使指定 `--nano`,也仍走原有的宿主机编译、链接 +流程,通过 import library 连接 `php.dll` 与 `phpx.dll`。Windows 不加载 +`swoole/php-nano`、`swoole/phpx` 的源码清单,也不会把它们的 C/C++ 源文件加入 +项目 `sources`。外部命令 API 与反引号语法依然会被拒绝;请求启动时还会从 Zend +函数表移除这些命令函数,避免变量函数或回调形式绕过编译期检查。 + +除运行时 sources、头文件目录、编译宏和链接输入外,Nano 与普通模式共用同一套 +命令行参数解析、TypePHP 代码生成、并行任务调度、编译进度条、输出路径规则以及 +`main(int $argc, array $argv)` 参数语义。 + ## 编译模式 TypePHP 支持三种构建模式,通过 `-m` / `--mode` 选择: diff --git a/README.md b/README.md index 53bd2f9c..67ab134d 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,44 @@ string(16) "Linux ..." > arguments, and must return `void`. Top-level executable statements are not > allowed; executable code belongs in a function or method. +### VM-free Nano executable + +Use `--nano` to compile one PHP source file together with PHP Nano and PHPX +sources. The result does not link `libphp` and contains no Zend opcode +interpreter: + +```bash +./bin/tpc.php --nano examples/hello.php +./hello +``` + +By default, the executable is emitted in the directory where `tpc` was invoked. +Normal and Nano builds share the `build` directory for generated code, objects, +and other intermediate files. Use `-o` to select a different output path. + +PHP and Composer remain build-time tools. On Linux, macOS, iOS, and Android, +the generated program uses the statically selected Nano runtime and its +file-only stream layer. Native Nano may use C11, C++17, and POSIX.1-2008, but +socket/DNS/network, remote streams, dynamic PHP loading, and process execution +remain unavailable. WASI is a smaller subset; direct calls to APIs missing from +that target are compile-time errors. + +On every platform, `--nano` rejects the VM entry paths `eval`, `include`, +`include_once`, `require`, and `require_once`, as well as anonymous classes. + +Windows uses a different build backend even when `--nano` is specified: it keeps +the existing host compile/link pipeline and connects to `php.dll` and `phpx.dll` +through their import libraries. It does not load the `swoole/php-nano` or +`swoole/phpx` source manifests, nor append their C/C++ files to project `sources`. +External-command APIs and backtick syntax are still rejected. Those command +functions are also removed from the Zend function table at request startup, so +indirect variable/callback calls cannot bypass the policy. + +Except for runtime sources, include directories, compile definitions, and link +inputs, Nano and normal mode share command-line parsing, TypePHP code generation, +parallel scheduling, the compilation progress bar, output path rules, and the +`main(int $argc, array $argv)` argument contract. + ## Compilation Modes TypePHP supports three build modes, selected with `-m` / `--mode`: diff --git a/completions/tpc.bash b/completions/tpc.bash index 05f1aab7..a0bff312 100644 --- a/completions/tpc.bash +++ b/completions/tpc.bash @@ -144,7 +144,7 @@ _typephp_tpc() return ;; -* ) - COMPREPLY=( $(compgen -W '-O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version -f --force -m --mode -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") ) + COMPREPLY=( $(compgen -W '--nano -O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version -f --force -m --mode -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") ) return ;; esac diff --git a/composer.json b/composer.json index b78a74c2..68079efd 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ } ], "bin": [ + "bin/tpc", "bin/tpc.php" ], "require": { @@ -22,6 +23,7 @@ "symfony/var-dumper": "^8.0", "symfony/yaml": "^8.0", "swoole/phpx": "~2.8.0", + "swoole/php-nano": "^8.6@dev", "ajaxray/ansikit": "^0.3", "ext-dom": "*" }, diff --git a/docs/en/INCOMPATIBLE_PHP_FEATURES.md b/docs/en/INCOMPATIBLE_PHP_FEATURES.md index b9c05d08..6bd80d24 100644 --- a/docs/en/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/en/INCOMPATIBLE_PHP_FEATURES.md @@ -108,16 +108,22 @@ incompatible with or more restrictive than standard PHP. becomes a C++ `T&`, and an exact `int/string/float/bool/array &$arg` on a statically resolved TypePHP call also uses `T&` without boxing or allocating a Zend reference. Rebinding, conditional/loop-local first binding, `unset`, - by-reference Closure capture, returning the local by reference, or storing the - reference in a property/array/global is rejected because the C++ reference may - not escape or change its target. + returning the local by reference, or storing the reference in a + property/array/global is rejected because the C++ reference may not escape or + change its target. Closure `use (&$value)` is an explicit degradation boundary: + before parsing the function body, the compiler marks the local for `php::Var` + storage starting at its first assignment, so an escaping Closure can retain a + normal Zend reference. A strongly typed parameter keeps its original call ABI + and is copied into a same-named local `php::Var` slot at function entry. - Dynamic calls and Closure calls still require explicit `std::ref()` / `toRef()`. TypePHP creates a call-scoped Zend reference, validates its type on write-back, and reports an error if dynamic code retains it beyond the call. Code requiring unrestricted PHP reference identity should initialize the local with `std::any()` and use the existing `php::Var`/`php::Ref` path. -- Fixed object, resource/stream, high-precision, Native/typed-object, Box, and - `std`-container locals cannot be referenced. These values already have +- Ordinary object, resource/stream, and high-precision locals also degrade to + `php::Var` when captured by reference. Native-object and `std`-container locals + cannot safely discard their compile-time storage layouts and still cannot be + captured this way. These values already have handle/reference-like semantics, while rebinding their statically typed local slot would weaken the type system. Typed object/static properties remain reference-capable because Zend attaches property type sources; PHP array diff --git a/docs/zh-cn/INCOMPATIBLE_PHP_FEATURES.md b/docs/zh-cn/INCOMPATIBLE_PHP_FEATURES.md index 31ae903a..d2b0437f 100644 --- a/docs/zh-cn/INCOMPATIBLE_PHP_FEATURES.md +++ b/docs/zh-cn/INCOMPATIBLE_PHP_FEATURES.md @@ -52,16 +52,18 @@ `$alias =& $value` 这类函数顶层的一次性绑定会生成 C++ `T&`;静态可解析的 TypePHP 调用中,精确的 `int/string/float/bool/array &$arg` 同样直接传递 `T&`,不装箱也不 创建 Zend reference。由于 C++ 引用不能改绑或逃逸,条件/循环内首次绑定、重新绑定、 - `unset`、Closure 按引用捕获、按引用返回局部变量,以及把引用保存到属性、数组或全局 - 槽位均会在编译期拒绝。 + `unset`、按引用返回局部变量,以及把引用保存到属性、数组或全局槽位均会在编译期拒绝。 + Closure 的 `use (&$value)` 是一个显式退化边界:编译器会在解析函数体前发现捕获,并从 + 第一次赋值开始把该局部变量保存为 `php::Var`,使逃逸闭包能够持有正常的 Zend reference。 + 强类型参数保持原调用 ABI,并在函数入口复制到同名的 `php::Var` 本地槽位。 - 动态函数、动态方法和 Closure 调用仍须显式使用 `std::ref()` / `toRef()`。TypePHP 为本次调用建立 Zend reference,返回时校验类型并写回;动态代码若把临时引用保留到 调用之外会得到明确错误。需要完整 PHP 引用身份时,应以 `std::any()` 初始化局部变量, 继续使用既有 `php::Var` / `php::Ref` 动态路径。 -- 固定 object、resource/stream、高精度值、Native/typed object、Box 与 `std` 容器局部 - 变量禁止取引用。这些值本身已有句柄或引用式语义,对其静态局部槽位改绑只会削弱类型 - 系统。Typed Property 仍可取引用,Zend 会附加属性 type source;PHP 数组元素仍是 - 可取引用的动态槽位。 +- 普通 object、resource/stream 和高精度局部变量在被 Closure 按引用捕获时同样退化为 + `php::Var`。Native object 和 `std` 容器不能安全丢弃其编译期存储布局,仍禁止这种捕获; + Typed Property 仍可取引用,Zend 会附加属性 type source;PHP 数组元素仍是可取引用的 + 动态槽位。 - 带 unpack 且尾部追加 named arguments 的调用会退化为动态调用,不能使用 native call。 ## 对象模型 diff --git a/docs/zh-cn/TYPED_NATIVE_REFERENCE_DESIGN.md b/docs/zh-cn/TYPED_NATIVE_REFERENCE_DESIGN.md index 385b1d84..4900d498 100644 --- a/docs/zh-cn/TYPED_NATIVE_REFERENCE_DESIGN.md +++ b/docs/zh-cn/TYPED_NATIVE_REFERENCE_DESIGN.md @@ -6,8 +6,10 @@ ## 目标 TypePHP 默认把 `int`、`string`、`float`、`bool`、`array` 保存为固定 C++ -类型。引用不得迫使这些变量退化为 `php::Var`,也不得允许动态代码静默改变 -它们的类型。 +类型。可静态证明不逃逸的原生引用不得迫使这些变量退化为 `php::Var`,也不得 +允许动态代码静默改变它们的类型。Closure 的 `use (&$var)` 是例外:函数体生成 +前的捕获分析会把对应局部变量加入退化表,使其从首次赋值起使用 `php::Var`;若捕获 +目标是强类型参数,则保留函数 ABI,并在入口建立同名 `php::Var` 本地槽位。 ```php $value = 100; @@ -313,7 +315,8 @@ $intRef = '100'; // 编译期错误 - `$alias =& $object->typedProperty`、`$alias =& $array[$key]` 继续产生动态 `REF`, 不产生长期持有 `RefWrap` 的 C++ 引用; - 禁止返回指向函数局部存储的 typed-ref; -- 禁止 Closure 按引用捕获 typed local,因为 Closure 可能超过栈帧生命周期; +- Closure 按引用捕获局部变量时,不建立指向 native local 的 typed-ref;该变量在函数 + 解析前自动退化为 `php::Var`,逃逸 Closure 使用 Zend reference; - 参数 typed-ref 可以继续传给静态可解析的 typed-ref 参数。 Generator、可挂起 Fiber 回调以及任何可能超过调用者栈帧的执行体,不得持有指向调用者 diff --git a/examples/hello.php b/examples/hello.php index e9504351..40242841 100644 --- a/examples/hello.php +++ b/examples/hello.php @@ -4,7 +4,6 @@ function main(): void { echo "Hello World!"; var_dump(PHP_VERSION); - var_dump(php_uname()); global $argv; var_dump($argv); diff --git a/phpunit/code/closure/use-reference-capture.php b/phpunit/code/closure/use-reference-capture.php index 64b5c260..899330f5 100644 --- a/phpunit/code/closure/use-reference-capture.php +++ b/phpunit/code/closure/use-reference-capture.php @@ -1,7 +1,7 @@ toStream(); + return $stream->getContents(); +} diff --git a/phpunit/code/nano-unavailable-host-function.php b/phpunit/code/nano-unavailable-host-function.php new file mode 100644 index 00000000..56c25348 --- /dev/null +++ b/phpunit/code/nano-unavailable-host-function.php @@ -0,0 +1,6 @@ +parseStr(); +} diff --git a/phpunit/code/reference-capture-auto-degrade.php b/phpunit/code/reference-capture-auto-degrade.php new file mode 100644 index 00000000..b8cf74e0 --- /dev/null +++ b/phpunit/code/reference-capture-auto-degrade.php @@ -0,0 +1,18 @@ +toBool() || $float->toBool() || $integerFloat->toBool() || $decimal->toBool(); +} diff --git a/phpunit/code/wasi-unavailable-host-function.php b/phpunit/code/wasi-unavailable-host-function.php new file mode 100644 index 00000000..ee23769b --- /dev/null +++ b/phpunit/code/wasi-unavailable-host-function.php @@ -0,0 +1,6 @@ +createForHostVersion()->parse(<<<'PHP' +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']); + } +} diff --git a/phpunit/src/Build/ComposerNativePackageTest.php b/phpunit/src/Build/ComposerNativePackageTest.php new file mode 100644 index 00000000..0c2d5ca8 --- /dev/null +++ b/phpunit/src/Build/ComposerNativePackageTest.php @@ -0,0 +1,109 @@ + */ + 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, + ], + ], + ]); + } +} diff --git a/phpunit/src/Build/NanoBuildBackendTest.php b/phpunit/src/Build/NanoBuildBackendTest.php new file mode 100644 index 00000000..8de617c0 --- /dev/null +++ b/phpunit/src/Build/NanoBuildBackendTest.php @@ -0,0 +1,31 @@ +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); + } +} diff --git a/phpunit/src/Build/NativeSourceProjectConfigTest.php b/phpunit/src/Build/NativeSourceProjectConfigTest.php new file mode 100644 index 00000000..a3fd6187 --- /dev/null +++ b/phpunit/src/Build/NativeSourceProjectConfigTest.php @@ -0,0 +1,72 @@ +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', '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' + + + + generated/main.php + generated/runtime.c + generated/main.cpp + + +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' + + generated/invalid.txt + +XML); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('must be PHP, C, or C++'); + NativeSourceProjectConfig::load($this->directory . '/project.xml'); + } +} diff --git a/phpunit/src/CliUsageTest.php b/phpunit/src/CliUsageTest.php new file mode 100644 index 00000000..5ba59c43 --- /dev/null +++ b/phpunit/src/CliUsageTest.php @@ -0,0 +1,54 @@ + ['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); + } +} diff --git a/phpunit/src/ClosureTest.php b/phpunit/src/ClosureTest.php index 4f99fa25..c35fbfb7 100644 --- a/phpunit/src/ClosureTest.php +++ b/phpunit/src/ClosureTest.php @@ -17,9 +17,15 @@ class ClosureTest extends \BaseTest $code = file_get_contents($generated); self::assertIsString($code); + self::assertStringContainsString('php::Var arr;', $code); + self::assertStringContainsString('php::Var value;', $code); self::assertStringContainsString('auto copy = [arr = arr]() mutable -> php::Var {', $code); - self::assertSame(2, substr_count($code, 'php::newClosureWithParameters(')); - self::assertSame(2, substr_count($code, 'php::ClosureStrictTypes::Enabled')); + self::assertStringContainsString('auto ref = [&arr]() mutable -> php::Var {', $code); + self::assertStringContainsString( + 'auto returnCapturedRef = [&value]() mutable -> php::Var {', + $code, + ); + self::assertStringNotContainsString('php::newClosureWithParameters(', $code); } public function testClosureRebindingIsRejectedAtCompileTime(): void diff --git a/phpunit/src/CompilerBaseApiTest.php b/phpunit/src/CompilerBaseApiTest.php index 5a91cdc0..81332633 100644 --- a/phpunit/src/CompilerBaseApiTest.php +++ b/phpunit/src/CompilerBaseApiTest.php @@ -459,6 +459,18 @@ PHP); $this->assertStringContainsString('App\\\\Worker\\\\Config\\\\PATH', $code); } + public function testInlineStringArrayKeyUsesZendStringPointer(): void + { + $this->setPropertyValue('noLiteralStrings', true); + + $code = $this->invokeMethod( + 'parseArrayKey', + new \PhpParser\Node\Scalar\String_('runtime'), + ); + + $this->assertSame('php::Str{ZEND_STRL("runtime")}.str()', $code); + } + public function testDynamicallyDefinedConstantsAreNotInternalConstants(): void { $name = 'AOT_USER_DEFINE_' . str_replace('.', '_', uniqid('', true)); diff --git a/phpunit/src/EntryScriptCodegenTest.php b/phpunit/src/EntryScriptCodegenTest.php index 39bcf607..242f205f 100644 --- a/phpunit/src/EntryScriptCodegenTest.php +++ b/phpunit/src/EntryScriptCodegenTest.php @@ -48,6 +48,35 @@ PHP, $extension, ); self::assertStringNotContainsString('php::eval("\\n', $extension); + self::assertStringNotContainsString('zend_disable_functions(', $extension); + } + + public function testNanoEntrypointForwardsArgcAndArgvWithTheSharedContract(): void + { + $source = $this->projectDir . '/main.php'; + file_put_contents($source, <<<'PHP' +projectDir); + $translator = $compiler; + $files = $compiler->prepareNanoSources( + [$source], + 'nano_args', + $this->projectDir . '/build', + false, + ); + $compiler->convert($files); + + $entrypoint = file_get_contents($this->projectDir . '/build/nano-entry-nano_args.cc'); + self::assertStringContainsString( + 'php_main(php::global("argc").toInt(), php::global("argv").toArray())', + $entrypoint, + ); } private function removeDirectory(string $directory): void diff --git a/phpunit/src/FixedReferenceStorageTest.php b/phpunit/src/FixedReferenceStorageTest.php index bffe79b4..6090fc9e 100644 --- a/phpunit/src/FixedReferenceStorageTest.php +++ b/phpunit/src/FixedReferenceStorageTest.php @@ -5,21 +5,41 @@ use TypePhp\Exception\TestError; final class FixedReferenceStorageTest extends BaseTest { - /** @dataProvider fixedStorageProvider */ - public function testFixedStorageCannotBeCapturedByReference(string $fixture, string $type): void + /** @dataProvider referenceCaptureDegradationProvider */ + public function testReferenceCaptureAutomaticallyDegradesLocalStorage(string $fixture): void { - $this->expectException(TestError::class); - $this->expectExceptionMessage('of fixed type ' . $type . '; initialize it with std::any()'); + $code = $this->compileFixture($fixture); + + self::assertStringContainsString('php::Var value;', $code); + } + + public static function referenceCaptureDegradationProvider(): iterable + { + yield 'object' => ['fixed-reference-generic-object.php']; + yield 'typed object' => ['fixed-reference-object.php']; + yield 'stream' => ['fixed-reference-stream.php']; + } + /** @dataProvider specializedStorageProvider */ + public function testSpecializedStorageCannotBeDegradedForReferenceCapture( + string $fixture, + string $message, + ): void { + $this->expectException(TestError::class); + $this->expectExceptionMessage($message); $this->compileFixture($fixture); } - public static function fixedStorageProvider(): iterable + public static function specializedStorageProvider(): iterable { - yield 'object' => ['fixed-reference-generic-object.php', 'php::Object']; - yield 'typed object' => ['fixed-reference-object.php', 'php::Object']; - yield 'stream' => ['fixed-reference-stream.php', 'php::Stream']; - yield 'std container' => ['fixed-reference-std-container.php', 'php::StdVector']; + yield 'std container' => [ + 'fixed-reference-std-container.php', + 'Std container variable `$value` cannot be degraded to var for Closure reference capture', + ]; + yield 'Native object' => [ + 'fixed-reference-native-object.php', + 'Native object variable `$value` cannot be degraded to var for Closure reference capture', + ]; } /** @dataProvider localClosureFixedReferenceProvider */ @@ -30,6 +50,7 @@ final class FixedReferenceStorageTest extends BaseTest { $code = $this->compileFixture($fixture); + self::assertStringContainsString('php::Var value;', $code); self::assertStringContainsString($capture, $code); self::assertStringNotContainsString('php::newClosureWithParameters(', $code); } @@ -92,12 +113,11 @@ final class FixedReferenceStorageTest extends BaseTest $this->compileFixture('typed-reference-parameter-return.php'); } - public function testFixedStaticStorageCannotBeCapturedByReference(): void + public function testFixedStaticStorageDegradesBeforeReferenceCapture(): void { - $this->expectException(TestError::class); - $this->expectExceptionMessage('variable $value of fixed type php::Str'); + $code = $this->compileFixture('fixed-reference-static.php'); - $this->compileFixture('fixed-reference-static.php'); + self::assertStringContainsString('value.toReference()', $code); } public function testFixedStorageUsesBridgeForToRefAtKnownDynamicReferenceBoundary(): void @@ -114,7 +134,24 @@ final class FixedReferenceStorageTest extends BaseTest $code = $this->compileFixture('fixed-reference-explicit-any.php'); self::assertStringContainsString('php::Var value', $code); - self::assertStringContainsString('value.toReference()', $code); + self::assertStringContainsString('auto closure = [&value]() mutable -> php::Var {', $code); + } + + public function testEscapingClosureUsesReferenceFromAutomaticallyDegradedStorage(): void + { + $code = $this->compileFixture('reference-capture-auto-degrade.php'); + + self::assertStringContainsString('php::Var value;', $code); + self::assertStringContainsString('{ value.toReference() }', $code); + self::assertStringContainsString('php::newClosureWithParameters(', $code); + self::assertStringContainsString( + 'php::Str __typephp_captured_arg_value', + $code, + ); + self::assertStringContainsString( + 'php::Var value = __typephp_captured_arg_value;', + $code, + ); } /** @dataProvider invalidTypedReferenceOperationProvider */ diff --git a/phpunit/src/LocalClosureCodegenTest.php b/phpunit/src/LocalClosureCodegenTest.php index 5c271f07..645909d8 100644 --- a/phpunit/src/LocalClosureCodegenTest.php +++ b/phpunit/src/LocalClosureCodegenTest.php @@ -34,8 +34,13 @@ final class LocalClosureCodegenTest extends BaseTest self::assertStringContainsString('direct(2L)', $code); self::assertStringNotContainsString('typephp_call_cached(direct', $code); - // Escaped values and dynamic references remain real Zend Closures. - self::assertSame(2, substr_count($code, 'php::newClosureWithParameters(')); - self::assertStringContainsString('typephp_call_cached(dynamicRef', $code); + // Only the escaping closure needs a real Zend Closure. The local reference + // capture uses the storage selected by the function degradation table. + self::assertSame(1, substr_count($code, 'php::newClosureWithParameters(')); + self::assertStringContainsString( + 'auto dynamicRef = [&dynamic]() mutable -> php::Var {', + $code, + ); + self::assertStringNotContainsString('typephp_call_cached(dynamicRef', $code); } } diff --git a/phpunit/src/NanoCapabilityPolicyTest.php b/phpunit/src/NanoCapabilityPolicyTest.php new file mode 100644 index 00000000..0091f129 --- /dev/null +++ b/phpunit/src/NanoCapabilityPolicyTest.php @@ -0,0 +1,271 @@ +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, "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); + } +} diff --git a/phpunit/src/Transform/NanoSyntaxValidationVisitorTest.php b/phpunit/src/Transform/NanoSyntaxValidationVisitorTest.php new file mode 100644 index 00000000..a647bb6e --- /dev/null +++ b/phpunit/src/Transform/NanoSyntaxValidationVisitorTest.php @@ -0,0 +1,142 @@ + */ + public static function unsupportedSyntax(): iterable + { + yield 'eval' => [' [' [' [' [' [' [' [' [' [ + ' [ + ' yield 1; }', + 'Fiber and Generator', + ]; + yield 'Fiber class' => [ + ' [ + ' [ + '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( + '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( + '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( + '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 ([ + 'parse($source); + self::assertNotNull($nodes); + try { + $traverser->traverse($nodes); + self::fail("Nano policy accepted unsupported source: {$source}"); + } catch (RuntimeException) { + self::addToAssertionCount(1); + } + } + } +} diff --git a/phpunit/src/UniversalMethodCallTest.php b/phpunit/src/UniversalMethodCallTest.php index 154e8436..63f6fc3d 100644 --- a/phpunit/src/UniversalMethodCallTest.php +++ b/phpunit/src/UniversalMethodCallTest.php @@ -22,4 +22,22 @@ class UniversalMethodCallTest extends \BaseTest $this->compile('void-method-call.php'); } + public function testHighPrecisionToBoolUsesNumericConversion(): void + { + global $translator; + $compiler = \TypePhp\CompilerTest::create(TYPEPHP_ROOT_PATH); + $translator = $compiler; + $source = TYPEPHP_ROOT_PATH . '/phpunit/code/universal-method-high-precision-to-bool.php'; + $compiler->addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + self::assertStringContainsString('php::BigInt::toBool(', $code); + self::assertStringContainsString('php::BigFloat::toBool(', $code); + self::assertStringContainsString('php::Decimal::toBool(', $code); + self::assertStringContainsString('php::toBigFloat(php::toInt(1L))', $code); + } + } diff --git a/phpunit/src/VarIntTypesTest.php b/phpunit/src/VarIntTypesTest.php index faae9d3e..8993aeba 100644 --- a/phpunit/src/VarIntTypesTest.php +++ b/phpunit/src/VarIntTypesTest.php @@ -85,14 +85,19 @@ final class VarIntTypesTest extends BaseTest self::assertStringNotContainsString('php::newClosureWithParameters(', $code); } - public function testExplicitAnySupportsReferenceCapture(): void + public function testExplicitAnyUsesDirectReferenceCaptureInNonEscapingClosure(): void { $code = $this->compileSource( $this->createCompiler(), TYPEPHP_ROOT_PATH . '/phpunit/code/any-reference-capture.php', ); + self::assertStringContainsString('php::Var changed', $code); - self::assertStringContainsString('changed.toReference()', $code); + self::assertStringContainsString( + 'auto set = [&changed]() mutable -> php::Var {', + $code, + ); + self::assertStringNotContainsString('php::newClosureWithParameters(', $code); } public function testDestructuringKeepsNativeTargetsAndConvertsDynamicItems(): void diff --git a/src/Analysis/ReferenceCaptureAnalyzer.php b/src/Analysis/ReferenceCaptureAnalyzer.php new file mode 100644 index 00000000..e56d3806 --- /dev/null +++ b/src/Analysis/ReferenceCaptureAnalyzer.php @@ -0,0 +1,85 @@ +|null $body + * @return array{captures: array, nonLocals: array} + */ + public function analyze(Node|array|null $body): array + { + $captures = []; + $nonLocals = []; + $this->scan($body, $captures, $nonLocals); + return ['captures' => $captures, 'nonLocals' => $nonLocals]; + } + + /** + * @param array $captures + * @param array $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); + } + } + } +} diff --git a/src/Backend/GccLikeBackend.php b/src/Backend/GccLikeBackend.php index b83e8274..484064c2 100644 --- a/src/Backend/GccLikeBackend.php +++ b/src/Backend/GccLikeBackend.php @@ -96,6 +96,16 @@ abstract class GccLikeBackend extends CompilerBackend $cmd .= ' -march=' . $config['march']; } + if (!empty($config['section_gc'])) { + $cmd .= ' -ffunction-sections -fdata-sections'; + } + + if (!empty($config['wasi_exceptions'])) { + $cmd .= ' -fwasm-exceptions' + . ' -mllvm -wasm-enable-sjlj' + . ' -mllvm -wasm-use-legacy-eh=false'; + } + if (!empty($config['target_platform'])) { $cmd .= ' --target=' . $config['target_platform']; } @@ -209,6 +219,10 @@ abstract class GccLikeBackend extends CompilerBackend $cmd .= ' ' . escapeshellarg($sourceFile); $cmd .= ' -o ' . escapeshellarg($outputFile); + if (!empty($options['c_std'])) { + $cmd .= ' -std=' . $options['c_std']; + } + if (!empty($options['include_paths'])) { $cmd .= ' ' . $this->formatIncludePaths($options['include_paths']); } diff --git a/src/Build/ComposerNativePackage.php b/src/Build/ComposerNativePackage.php new file mode 100644 index 00000000..7b863910 --- /dev/null +++ b/src/Build/ComposerNativePackage.php @@ -0,0 +1,228 @@ + $includeDirs + * @param list $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 */ + 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 */ + 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; + } +} diff --git a/src/Build/NanoBuildBackend.php b/src/Build/NanoBuildBackend.php new file mode 100644 index 00000000..b9584dea --- /dev/null +++ b/src/Build/NanoBuildBackend.php @@ -0,0 +1,23 @@ +, + * packageSources: list, + * includeDirs: list, + * 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 $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 \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; + } +} diff --git a/src/Build/NativeBuildConfigurationTrait.php b/src/Build/NativeBuildConfigurationTrait.php index cb3c1125..4a845319 100644 --- a/src/Build/NativeBuildConfigurationTrait.php +++ b/src/Build/NativeBuildConfigurationTrait.php @@ -138,6 +138,13 @@ trait NativeBuildConfigurationTrait protected function getIncludePaths(): array { + if ($this->isNanoMode()) { + return array_values(array_unique([ + ...$this->nanoRuntimeIncludePaths, + $this->getBuildDir() . '/include', + ])); + } + $sdkDir = $this->getTargetSdkDir(); if ($sdkDir !== null) { return [ @@ -179,6 +186,10 @@ trait NativeBuildConfigurationTrait protected function getLibraryPaths(): array { + if ($this->isNanoMode()) { + return []; + } + $sdkDir = $this->getTargetSdkDir(); if ($sdkDir !== null) { return [$sdkDir . '/lib']; @@ -207,6 +218,10 @@ trait NativeBuildConfigurationTrait */ protected function getLibraries(): array { + if ($this->isNanoMode()) { + return []; + } + $sdkDir = $this->getFullStaticSdkDir(); if ($sdkDir !== null) { // Fully-static: both archives are self-contained. libphpx.a comes diff --git a/src/Build/NativeCommandOptionsTrait.php b/src/Build/NativeCommandOptionsTrait.php index f113b8ee..ef1b362e 100644 --- a/src/Build/NativeCommandOptionsTrait.php +++ b/src/Build/NativeCommandOptionsTrait.php @@ -33,6 +33,18 @@ trait NativeCommandOptionsTrait $userDefines[] = 'TYPEPHP_NO_MAIN=1'; $userDefines[] = $this->getLibraryExportsMacroName() . '=1'; } + if ($this->isNanoMode()) { + array_push( + $userDefines, + 'TYPEPHP_NATIVE=1', + 'PHP_NANO=1', + 'PHPX_NANO=1', + '_POSIX_C_SOURCE=200809L', + ); + if ($this->isWasiTarget()) { + $userDefines[] = 'ZEND_MM_ERROR=0'; + } + } $values = [ 'include_paths' => $includePaths, @@ -47,6 +59,8 @@ trait NativeCommandOptionsTrait 'prof_output' => $this->targetName . '.prof', 'user_defines' => $userDefines, 'lto' => $this->enableLto, + 'section_gc' => $this->isNanoMode(), + 'wasi_exceptions' => $this->isNanoMode() && $this->isWasiTarget(), ]; if ($this->debug && $this->isWindows()) { @@ -90,7 +104,8 @@ trait NativeCommandOptionsTrait protected function getCCompileCommandOptions(): CompileOptions { $options = $this->getCommonCompileCommandOptions(); - return $options->with('suppressed_warnings', ['4244', '4146']); + $options = $options->with('suppressed_warnings', ['4244', '4146']); + return $this->isNanoMode() ? $options->with('c_std', 'c11') : $options; } protected function getPrecompiledHeaderCompileCommandOptions(): CompileOptions @@ -145,6 +160,15 @@ trait NativeCommandOptionsTrait $targetPlatform = $this->getFullStaticTargetTriple(); $ldflags = trim('-static -B ' . escapeshellarg($this->getFullStaticMuslDir()) . ' ' . $ldflags); } + if ($this->isNanoMode()) { + $gcSections = $this->isMacos() ? '-Wl,-dead_strip' : '-Wl,--gc-sections'; + $ldflags = trim($gcSections . ' ' . $ldflags); + if ($this->isWasiTarget()) { + $ldflags = trim( + '-fwasm-exceptions -lsetjmp -lunwind ' . $ldflags, + ); + } + } $options = [ 'library_paths' => $libraryPaths, @@ -158,7 +182,9 @@ trait NativeCommandOptionsTrait 'target_platform' => $targetPlatform, ]; - $rpaths = $this->getPlatform()->getDefaultRpaths($this->getPhpxDir(), $this->getPhpDir()); + $rpaths = $this->isNanoMode() + ? [] + : $this->getPlatform()->getDefaultRpaths($this->getPhpxDir(), $this->getPhpDir()); if (!empty($rpaths)) { $options['rpath'] = $rpaths; } diff --git a/src/Build/NativeDependencyAuditor.php b/src/Build/NativeDependencyAuditor.php new file mode 100644 index 00000000..9fa5ee95 --- /dev/null +++ b/src/Build/NativeDependencyAuditor.php @@ -0,0 +1,105 @@ + $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; + } +} diff --git a/src/Build/NativeSourceProjectBuilder.php b/src/Build/NativeSourceProjectBuilder.php new file mode 100644 index 00000000..76d86c2e --- /dev/null +++ b/src/Build/NativeSourceProjectBuilder.php @@ -0,0 +1,481 @@ +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 $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 $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 */ + 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('/(? 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 */ + 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 */ + 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 $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 $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 $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; + } +} diff --git a/src/Build/NativeSourceProjectConfig.php b/src/Build/NativeSourceProjectConfig.php new file mode 100644 index 00000000..44aed5ba --- /dev/null +++ b/src/Build/NativeSourceProjectConfig.php @@ -0,0 +1,156 @@ + $phpSources + * @param list $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 ``'); + } + + $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 ``'); + } + $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 ``'); + } + + 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; + } +} diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index fe60f05f..0450f4b1 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -19,6 +19,76 @@ use TypePhp\Platform\Windows; trait SourcePipelineTrait { + /** + * Prepare PHP inputs for the Composer php-nano source-composition build. + * + * The compiler itself still runs on Zend PHP, but generated sources do not + * inspect or link the host libphp installation. + * + * @param list $files + * @return list + */ + public function prepareNanoSources( + array $files, + string $targetName, + string $buildDir, + bool $wasi, + ): array { + if ($files === []) { + return []; + } + + $this->nanoMode = true; + $this->nanoPolicyMode = true; + // Persistent literal wrappers are normally constructed after libphp has + // initialized Zend. A standalone executable starts php-nano from main(), + // so keep literals inside function scope for now. + $this->noLiteralStrings = true; + $this->buildMode = self::BUILD_MODE_BIN; + $this->targetPlatform = $wasi ? 'wasm32-wasip2' : ''; + $this->setTargetName($targetName); + $this->setBuildDir($buildDir); + + $resolvedFiles = []; + foreach ($files as $file) { + $resolved = realpath($file); + if ($resolved === false || !is_file($resolved) + || !FileScanner::isPhpFile($resolved)) { + throw new \RuntimeException("Invalid TypePHP native source: {$file}"); + } + $resolvedFiles[] = $resolved; + $this->sourceDirs[] = dirname($resolved); + } + $resolvedFiles = array_values(array_unique($resolvedFiles)); + $this->sourceDirs = array_values(array_unique($this->sourceDirs)); + + $this->discoverNativeClassDeclarations($resolvedFiles); + foreach ($resolvedFiles as $key => $file) { + try { + $this->prepareFile($file); + } catch (Unsupported $exception) { + $this->output( + ' unsupported syntax: ' . $exception->getMessage() + . "\n skip: {$file}\n", + 'error', + ); + unset($resolvedFiles[$key]); + } catch (SyntaxError $exception) { + $this->output( + ' syntax error: ' . $exception->getMessage() + . "\n skip: {$file}\n", + 'error', + ); + unset($resolvedFiles[$key]); + } + } + + $resolvedFiles = array_values($resolvedFiles); + $this->composeTraitDeclarations($resolvedFiles); + $this->discoverNativeGlobalObjects($resolvedFiles); + return $this->getSortedFiles($resolvedFiles); + } + public function addFiles(array $files): void { $this->sourceDirs = array_merge($this->sourceDirs, $files); @@ -79,35 +149,40 @@ trait SourcePipelineTrait { $files = $this->getFiles($path); - if ($this->isBuildModeEmbed() && $this->getPlatform() instanceof Linux) { - try { - $phpDir = (new LibPhpInstaller())->ensure($this->getPhpDir()) ?? $this->getPhpDir(); - } catch (\Throwable $e) { - $this->error('Unable to install libphp.so: ' . $e->getMessage()); + // Source-composed Nano does not consume the host PHP/PHPX runtime. + // Windows Nano deliberately leaves nanoMode=false and therefore keeps + // this original DLL/import-library validation path. + if (!$this->isNanoMode()) { + if ($this->isBuildModeEmbed() && $this->getPlatform() instanceof Linux) { + try { + $phpDir = (new LibPhpInstaller())->ensure($this->getPhpDir()) ?? $this->getPhpDir(); + } catch (\Throwable $e) { + $this->error('Unable to install libphp.so: ' . $e->getMessage()); + } + } else { + $phpDir = $this->getPhpDir(); } - } else { - $phpDir = $this->getPhpDir(); - } - if (!($this->getPlatform() instanceof Wasi)) { - $this->validatePhpRuntimeMinimum($phpDir); - } + if (!($this->getPlatform() instanceof Wasi)) { + $this->validatePhpRuntimeMinimum($phpDir); + } - if ($this->getPlatform() instanceof Linux) { - try { - (new LibPhpxInstaller())->ensure($this->getPhpxDir(), $phpDir); - } catch (\Throwable $e) { - $this->error('Unable to build libphpx.so: ' . $e->getMessage()); + if ($this->getPlatform() instanceof Linux) { + try { + (new LibPhpxInstaller())->ensure($this->getPhpxDir(), $phpDir); + } catch (\Throwable $e) { + $this->error('Unable to build libphpx.so: ' . $e->getMessage()); + } } - } - // Pre-check the phpx library only at the PHP script entry (bin/tpc.php): - // a missing library fails immediately rather than surfacing later during - // file processing/compilation. The compiled tpc executable has libphpx - // loaded by the dynamic linker before entering main(), so checking here - // is neither needed nor possible. - if (defined('TYPEPHP_PHP_SCRIPT_ENTRY') && !($this->getPlatform() instanceof Wasi)) { - $this->validatePhpxLibrary(); + // Pre-check the phpx library only at the PHP script entry (bin/tpc.php): + // a missing library fails immediately rather than surfacing later during + // file processing/compilation. The compiled tpc executable has libphpx + // loaded by the dynamic linker before entering main(), so checking here + // is neither needed nor possible. + if (defined('TYPEPHP_PHP_SCRIPT_ENTRY') && !($this->getPlatform() instanceof Wasi)) { + $this->validatePhpxLibrary(); + } } $this->validateCompilerToolchain(); @@ -117,7 +192,8 @@ trait SourcePipelineTrait // All Windows build modes depend on the PHPX import library and runtime. // Other platforms only run the existing checks in embedded build mode. - if ($this->isBuildModeEmbed() || $this->getPlatform() instanceof Windows) { + if (!$this->isNanoMode() + && ($this->isBuildModeEmbed() || $this->getPlatform() instanceof Windows)) { foreach ($this->getPlatform()->getBuildLibraryWarnings( $this->getPhpDir(), $this->getPhpxDir(), @@ -306,8 +382,12 @@ trait SourcePipelineTrait // runtime data declarations $this->genFunctionDeclarations($this->getIncludeDir() . "/php_{$this->targetName}_func_decl.h"); $this->genDataDeclarations($this->getIncludeDir() . "/php_{$this->targetName}_data_decl.h"); - // Generate the extension module source file + // Nano keeps the ordinary statically registered Zend class/module + // metadata, then adds a direct native process entry beside it. $sourceFiles[] = $this->genExtension(); + if ($this->isNanoMode()) { + $sourceFiles[] = $this->genNanoEntrypoint(); + } return $sourceFiles; } finally { diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 7bad4e5c..26d7d3a6 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -200,9 +200,9 @@ class CompilerBase implements PropertyAccessContext ]; /** - * APIs which cannot have the same semantics in Wasmtime and a browser. - * Keep this list at the language boundary so a WASI build never degrades - * into a link error or a browser-only implementation. + * APIs omitted by the smaller WASI capability profile. Keep the rejection + * at the language boundary so a direct call never degrades into a link + * error or an internal ENOTSUP compatibility stub. */ private const array WASI_UNSUPPORTED_FUNCTIONS = [ 'exec', @@ -215,17 +215,31 @@ class CompilerBase implements PropertyAccessContext 'proc_terminate', 'shell_exec', 'system', + 'flock', + 'umask', + 'chown', + 'chgrp', + 'lchown', + 'lchgrp', 'fsockopen', 'pfsockopen', 'stream_socket_accept', 'stream_socket_client', 'stream_socket_enable_crypto', + 'stream_socket_get_crypto_status', 'stream_socket_get_name', 'stream_socket_pair', 'stream_socket_recvfrom', 'stream_socket_sendto', 'stream_socket_server', 'stream_socket_shutdown', + 'gethostbyaddr', + 'gethostbyname', + 'gethostbynamel', + 'gethostname', + 'dns_check_record', + 'dns_get_mx', + 'dns_get_record', ]; private const array WASI_UNSUPPORTED_FUNCTION_PREFIXES = [ @@ -233,6 +247,89 @@ class CompilerBase implements PropertyAccessContext 'posix_', 'socket_', ]; + + /** APIs excluded by php-nano's C/C++/POSIX capability model. */ + private const array NANO_UNSUPPORTED_FUNCTIONS = [ + 'dl', + 'exec', + 'passthru', + 'popen', + 'proc_close', + 'proc_get_status', + 'proc_nice', + 'proc_open', + 'proc_terminate', + 'shell_exec', + 'system', + 'getenv', + 'putenv', + 'set_time_limit', + 'parse_str', + 'fsockopen', + 'pfsockopen', + 'stream_select', + 'stream_get_transports', + 'stream_get_filters', + 'stream_filter_register', + 'stream_bucket_make_writeable', + 'stream_bucket_prepend', + 'stream_bucket_append', + 'stream_bucket_new', + 'stream_socket_accept', + 'stream_socket_client', + 'stream_socket_enable_crypto', + 'stream_socket_get_crypto_status', + 'stream_socket_get_name', + 'stream_socket_pair', + 'stream_socket_recvfrom', + 'stream_socket_sendto', + 'stream_socket_server', + 'stream_socket_shutdown', + 'gethostbyaddr', + 'gethostbyname', + 'gethostbynamel', + 'gethostname', + 'dns_check_record', + 'dns_get_mx', + 'dns_get_record', + 'header', + 'header_remove', + 'headers_list', + 'headers_sent', + 'http_response_code', + 'mail', + 'openlog', + 'closelog', + 'syslog', + ]; + + /** Calls forbidden by Nano policy even when the full Windows PHP DLL is used. */ + private const array NANO_POLICY_UNSUPPORTED_FUNCTIONS = [ + 'exec', + 'passthru', + 'pcntl_exec', + 'popen', + 'proc_close', + 'proc_get_status', + 'proc_nice', + 'proc_open', + 'proc_terminate', + 'shell_exec', + 'system', + ]; + + private const array NANO_POLICY_UNSUPPORTED_FUNCTION_PREFIXES = [ + 'proc_', + ]; + + private const array NANO_UNSUPPORTED_FUNCTION_PREFIXES = [ + 'pcntl_', + 'posix_', + 'socket_', + 'curl_', + 'ftp_', + 'opcache_', + ]; public const int DECL_TYPE_OF_RETURN = 1; public const int DECL_TYPE_OF_PROPERTY = 2; public const int DECL_TYPE_OF_CONST = 3; @@ -422,6 +519,16 @@ class CompilerBase implements PropertyAccessContext protected array $userDefines = []; // --define / -D: user-provided preprocessor macros protected bool $enableLto = false; // --lto: enable Link Time Optimization (-flto) protected bool $fullStatic = false; // --full-static: link against the bundled fully-static SDK + /** Generate a VM-less program entry for the Composer php-nano runtime. */ + protected bool $nanoMode = false; + /** Enforce the VM-free and external-command restrictions of `--nano`. */ + protected bool $nanoPolicyMode = false; + /** @var list Include directories published by Nano Composer packages. */ + protected array $nanoRuntimeIncludePaths = []; + /** @var array Package source files compiled into a Nano executable. */ + protected array $nanoRuntimeSources = []; + /** Latest header timestamp used by the shared object-cache path. */ + protected int $nanoRuntimeHeaderMtime = 0; protected string $file; protected string $dir; @@ -697,6 +804,16 @@ class CompilerBase implements PropertyAccessContext return $target === 'wasm32-unknown-wasip2' || $target === 'wasm32-wasip2'; } + public function isNanoMode(): bool + { + return $this->nanoMode; + } + + public function isNanoPolicyMode(): bool + { + return $this->nanoMode || $this->nanoPolicyMode; + } + protected function assertWasiFunctionSupported(NodeAbstract $expr, string $name): void { if (!$this->isWasiTarget()) { @@ -714,6 +831,43 @@ class CompilerBase implements PropertyAccessContext } } + protected function assertNanoFunctionSupported(NodeAbstract $expr, string $name): void + { + if (!$this->isNanoPolicyMode()) { + return; + } + + $name = strtolower(ltrim($name, '\\')); + if (in_array($name, self::NANO_POLICY_UNSUPPORTED_FUNCTIONS, true)) { + $this->fatalError($expr, "Function `{$name}` is not supported in nano mode"); + } + foreach (self::NANO_POLICY_UNSUPPORTED_FUNCTION_PREFIXES as $prefix) { + if (str_starts_with($name, $prefix)) { + $this->fatalError($expr, "Function `{$name}` is not supported in nano mode"); + } + } + + // Windows Nano uses the complete PHP/PHPX DLL set. Only the common + // policy above applies; php-nano's smaller host surface is Unix/WASI. + if (!$this->isNanoMode()) { + return; + } + + if (in_array($name, self::NANO_UNSUPPORTED_FUNCTIONS, true)) { + $this->fatalError($expr, "Function `{$name}` is not supported in nano mode"); + } + foreach (self::NANO_UNSUPPORTED_FUNCTION_PREFIXES as $prefix) { + if (str_starts_with($name, $prefix)) { + $this->fatalError($expr, "Function `{$name}` is not supported in nano mode"); + } + } + } + + protected function getNanoPolicyDisabledFunctionList(): string + { + return implode(',', self::NANO_POLICY_UNSUPPORTED_FUNCTIONS); + } + public function isBuildModeBin(): bool { return $this->buildMode === self::BUILD_MODE_BIN; @@ -1600,9 +1754,11 @@ class CompilerBase implements PropertyAccessContext { $this->assertNotNativeObjectArrayKey($expr); $key = $this->parseIdentifier($expr); - if (str_starts_with($key, self::LITERAL_STRING_GETTER . '(')) { + if ($expr instanceof Node\Scalar\String_) { // Array initializers and setters use zend_string* keys, while item() - // uses php::String to avoid an ambiguous conversion to Variant. + // uses php::String to avoid an ambiguous conversion to Variant. This + // must also cover inline literals used by native/Nano builds, where + // no persistent literal-string table is generated. return $keepStringObject ? $key : "{$key}.str()"; } if ($this->isZeroLiteral($expr)) { @@ -4925,6 +5081,9 @@ class CompilerBase implements PropertyAccessContext protected function parseShellExec(Expr\ShellExec $expr): string { + if ($this->isNanoPolicyMode()) { + $this->fatalError($expr, 'Backtick shell execution is not supported in nano mode'); + } if ($this->isWasiTarget()) { $this->fatalError($expr, 'Backtick shell execution is not supported by the WASI target'); } diff --git a/src/Context/CompilationStateTrait.php b/src/Context/CompilationStateTrait.php index 6fa3e5f4..bd63f482 100644 --- a/src/Context/CompilationStateTrait.php +++ b/src/Context/CompilationStateTrait.php @@ -14,12 +14,82 @@ use TypePhp\Entity\ClassDef; use TypePhp\Entity\FunctionDef; use TypePhp\Entity\InterfaceDef; use TypePhp\Exception\Skip; +use TypePhp\Analysis\ReferenceCaptureAnalyzer; +use TypePhp\Type; trait CompilationStateTrait { protected function addLocalVar(string $name, string $type): void { - $this->context->localVars[$name] = $type; + $this->context->localVars[$name] = $this->context->varTypeDegradations[$name] ?? $type; + } + + /** + * Build the function-local degradation table before parsing any statement. + * A local captured through use (&$var) must own a Zend-compatible php::Var + * slot from its first assignment. + * + * @param NodeAbstract|list|null $body + */ + protected function prepareReferenceCaptureDegradations( + NodeAbstract|array|null $body, + bool $degradeArguments = false, + ): void + { + $analysis = (new ReferenceCaptureAnalyzer())->analyze($body); + foreach ($analysis['captures'] as $sourceName => $_) { + $name = $this->escapeVarName($sourceName); + if (isset($this->context->arguments[$name])) { + $type = $this->getRawVarType($name); + if (!$degradeArguments + || $type === Type::VAR + || $type === Type::REF + || Type::isTypedRefType($type) + || $this->isNativeObjectVar($name) + ) { + continue; + } + + // Keep the public/native ABI fixed, but make the PHP-visible + // parameter a local Var copy. Its declaration is emitted in the + // function preamble under the original source name. + $this->context->varTypeDegradations[$name] = Type::VAR; + $this->context->localVars[$name] = Type::VAR; + unset( + $this->context->objects[$name], + $this->context->declaredObjects[$name], + $this->context->stableObjects[$name], + $this->context->exactObjects[$name], + $this->context->nonNullNativeObjects[$name], + ); + continue; + } + + $this->context->varTypeDegradations[$name] = Type::VAR; + if (!isset($analysis['nonLocals'][$sourceName]) && !$this->hasVar($name)) { + // Reserve the slot now so object/std/native assignment paths do + // not commit metadata for a fixed local before seeing the use. + $this->context->localVars[$name] = Type::VAR; + } + } + } + + protected function getDegradedArgumentStorageName(string $name): string + { + return '__typephp_captured_arg_' . $name; + } + + protected function genDegradedArgumentLocals(): string + { + $code = ''; + foreach ($this->context->arguments as $name => $_type) { + if (!isset($this->context->varTypeDegradations[$name])) { + continue; + } + $code .= $this->getIndent() . Type::VAR . ' ' . $name . ' = ' + . $this->getDegradedArgumentStorageName($name) . ';' . PHP_EOL; + } + return $code; } protected function addTypedRefLocal(string $name, string $target, string $type): void @@ -67,6 +137,7 @@ trait CompilationStateTrait if ($this->hasVar($name)) { $this->fatalError($var, 'Duplicate variable `$' . $var->name . '`'); } + $type = $this->context->varTypeDegradations[$name] ?? $type; $this->context->staticVars[$name] = $type; // A static variable is actually a reference to a global variable. $globalVar = $this->escapeStaticVar($name); diff --git a/src/Context/FunctionContext.php b/src/Context/FunctionContext.php index a4e52591..d342bbf8 100644 --- a/src/Context/FunctionContext.php +++ b/src/Context/FunctionContext.php @@ -68,6 +68,8 @@ class FunctionContext */ public array $stdContainers = []; public array $localVars = []; + /** @var array Local variable => forced fallback storage type. */ + public array $varTypeDegradations = []; /** @var array Locals explicitly created through std::int/float/bool. */ public array $explicitNativeTypeVars = []; /** @var array C++ initializers folded into function-scope local declarations. */ @@ -145,6 +147,7 @@ class FunctionContext public function __construct() { $this->localVars = []; + $this->varTypeDegradations = []; $this->explicitNativeTypeVars = []; $this->localVarInitializers = []; $this->localClosureCandidates = []; diff --git a/src/Generator/ClosureGenerator.php b/src/Generator/ClosureGenerator.php index e1f130eb..01d5085b 100644 --- a/src/Generator/ClosureGenerator.php +++ b/src/Generator/ClosureGenerator.php @@ -143,6 +143,9 @@ trait ClosureGenerator $this->inGeneratorBody = false; $this->indentLevel = $entryIndent + 1; + $body = $expr instanceof Expr\ArrowFunction ? $expr->expr : $expr->stmts; + $this->prepareReferenceCaptureDegradations($body); + $returnType = $expr->returnType; $returnTypeName = $returnType instanceof Node\Identifier ? strtolower($returnType->name) @@ -236,7 +239,12 @@ trait ClosureGenerator $rawType = $this->getRawVarType($name); $valueType = Type::getReferencedType($rawType); if ($useItem->byRef) { - $captureType = Type::getReferenceType($valueType); + // Reference-captured locals are pre-degraded to php::Var. A + // native, non-escaping lambda can bind that slot directly; + // it does not need to manufacture a Zend reference wrapper. + $captureType = $valueType === Type::VAR + ? Type::VAR + : Type::getReferenceType($valueType); if ($captureType === null) { return null; } @@ -450,6 +458,8 @@ trait ClosureGenerator $this->context = new FunctionContext(); $this->context->inClosure = true; + $body = $expr instanceof Expr\ArrowFunction ? $expr->expr : $expr->stmts; + $this->prepareReferenceCaptureDegradations($body); if (!$isGenerator && ($expr->returnType instanceof NullableType || $expr->returnType instanceof UnionType @@ -656,6 +666,9 @@ trait ClosureGenerator $this->inGeneratorBody = true; $this->indentLevel++; + $body = $expr instanceof Expr\ArrowFunction ? $expr->expr : $expr->stmts; + $this->prepareReferenceCaptureDegradations($body); + try { foreach ($capturedNames as $i => $capturedName) { $code .= $this->getIndent() . Type::VAR . ' ' . $capturedName . ' = vars_.get(' . $i . ');' . PHP_EOL; diff --git a/src/Generator/FiberGenerator.php b/src/Generator/FiberGenerator.php index 68d1307c..83803567 100644 --- a/src/Generator/FiberGenerator.php +++ b/src/Generator/FiberGenerator.php @@ -279,6 +279,8 @@ trait FiberGenerator $this->inGeneratorBody = true; $this->indentLevel++; + $this->prepareReferenceCaptureDegradations($v->stmts); + foreach ($functionDef->argInfoList as $i => $argInfo) { $code .= $this->getIndent() . Type::VAR . ' ' . $argInfo->name . ' = vars_.get(' . $i . ');' . PHP_EOL; $this->addArgument($argInfo->name, Type::VAR); diff --git a/src/Metadata/Constants.php b/src/Metadata/Constants.php index f337b486..57536d21 100644 --- a/src/Metadata/Constants.php +++ b/src/Metadata/Constants.php @@ -129,6 +129,12 @@ class Constants ]; public const array COMPILER_OPTIONS = [ + 'nano' => [ + 'longPrefix' => 'nano', + 'description' => 'Enable VM-free Nano policy (php-nano runtime outside Windows)', + 'required' => false, + 'noValue' => true, + ], 'optimize' => [ 'prefix' => 'O', 'longPrefix' => 'optimize', diff --git a/src/NativeClass/NativeClassSupportTrait.php b/src/NativeClass/NativeClassSupportTrait.php index caae7163..0d42764b 100644 --- a/src/NativeClass/NativeClassSupportTrait.php +++ b/src/NativeClass/NativeClassSupportTrait.php @@ -1575,6 +1575,7 @@ trait NativeClassSupportTrait protected function getNativeMethodParameterDeclarations( FunctionDef $function, ?int $parameterCount = null, + bool $useDegradedArgumentNames = false, ): string { $args = []; @@ -1582,6 +1583,12 @@ trait NativeClassSupportTrait ? $function->argInfoList : array_slice($function->argInfoList, 0, $parameterCount); foreach ($arguments as $argument) { + if ($useDegradedArgumentNames + && isset($this->context->varTypeDegradations[$argument->name]) + ) { + $argument = clone $argument; + $argument->name = $this->getDegradedArgumentStorageName($argument->name); + } if ($argument->variadic) { $declaration = Type::ARRAY . ' ' . $argument->name; } else { diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index 45435532..4780fac1 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -404,6 +404,15 @@ trait AssignOpTrait } if ($this->isNativeObjectClass($rightClass)) { + if ($this->isVarExpr($left)) { + $degradedName = $this->parseVariable($left); + if (isset($this->context->varTypeDegradations[$degradedName])) { + $this->fatalError( + $left, + "Native object variable `\${$degradedName}` cannot be degraded to var for Closure reference capture", + ); + } + } $allowed = false; if ($this->isVarExpr($left)) { $leftName = $this->parseVariable($left); @@ -615,6 +624,12 @@ trait AssignOpTrait if ($this->isStdClassExpr($right->class)) { $stdMethod = strtolower($right->name->toString()); if (in_array($stdMethod, ['array', 'vector', 'map', 'orderedmap'], true)) { + if (isset($this->context->varTypeDegradations[$var])) { + $this->fatalError( + $left, + "Std container variable `\${$var}` cannot be degraded to var for Closure reference capture", + ); + } if ($this->hasScopeGlobalVar($var) || $this->hasStaticVar($var)) { $this->assertNativeStdContainerFunctionLocal($right); } diff --git a/src/Parser/FunctionCallTrait.php b/src/Parser/FunctionCallTrait.php index d47a0cf5..65b972ca 100644 --- a/src/Parser/FunctionCallTrait.php +++ b/src/Parser/FunctionCallTrait.php @@ -142,8 +142,13 @@ trait FunctionCallTrait "Native classes do not support runtime class introspection; use {$replacement}", ); } + // Capability policy is determined by the target, not by the + // extensions loaded into the build-time PHP process. Otherwise a + // forbidden direct call could bypass validation merely because + // that host PHP does not expose the function. + $this->assertWasiFunctionSupported($expr, $globalName); + $this->assertNanoFunctionSupported($expr, $globalName); if ($this->isInternalFunction($globalName)) { - $this->assertWasiFunctionSupported($expr, $globalName); $this->markInternalFunctionCallbackCall($globalName, $expr->args); } if (in_array($globalName, Constants::UNSUPPORTED_FUNCTIONS, true)) { diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index da434992..f1fe3ae3 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -445,7 +445,7 @@ trait MethodCallTrait } if ($type === Type::BIGFLOAT) { if ($argType === Type::INT) { - return 'php::toBigFloat(' . $valueExpr . ')'; + return 'php::toBigFloat(php::toInt(' . $valueExpr . '))'; } if ($argType === Type::FLOAT) { return 'php::toBigFloat(' . $valueExpr . ')'; diff --git a/src/Parser/TypeConversionTrait.php b/src/Parser/TypeConversionTrait.php index 8c3ca8f7..2b1d3a7b 100644 --- a/src/Parser/TypeConversionTrait.php +++ b/src/Parser/TypeConversionTrait.php @@ -125,7 +125,7 @@ trait TypeConversionTrait protected function convertBigFloatExpr(string $expr, string $fromType = ''): string { if ($fromType === Type::INT) { - return 'php::toBigFloat(' . $expr . ')'; + return 'php::toBigFloat(php::toInt(' . $expr . '))'; } if ($fromType === Type::FLOAT) { return 'php::toBigFloat(' . $expr . ')'; diff --git a/src/Parser/UniversalMethodCall.php b/src/Parser/UniversalMethodCall.php index 03b18b0a..e9e5d8a7 100644 --- a/src/Parser/UniversalMethodCall.php +++ b/src/Parser/UniversalMethodCall.php @@ -430,9 +430,9 @@ trait UniversalMethodCall } protected const array TO_CONVERT_FN = [ - Type::BIGINT => ['toInt' => 'php::BigInt::toInt', 'toFloat' => 'php::BigInt::toFloat', 'toString' => 'php::BigInt::toString'], - Type::BIGFLOAT => ['toInt' => 'php::BigFloat::toInt', 'toFloat' => 'php::BigFloat::toFloat', 'toString' => 'php::BigFloat::toString'], - Type::DECIMAL => ['toInt' => 'php::Decimal::toInt', 'toFloat' => 'php::Decimal::toFloat', 'toString' => 'php::Decimal::toString'], + Type::BIGINT => ['toInt' => 'php::BigInt::toInt', 'toFloat' => 'php::BigInt::toFloat', 'toString' => 'php::BigInt::toString', 'toBool' => 'php::BigInt::toBool'], + Type::BIGFLOAT => ['toInt' => 'php::BigFloat::toInt', 'toFloat' => 'php::BigFloat::toFloat', 'toString' => 'php::BigFloat::toString', 'toBool' => 'php::BigFloat::toBool'], + Type::DECIMAL => ['toInt' => 'php::Decimal::toInt', 'toFloat' => 'php::Decimal::toFloat', 'toString' => 'php::Decimal::toString', 'toBool' => 'php::Decimal::toBool'], ]; /** @@ -600,6 +600,30 @@ trait UniversalMethodCall */ protected function parseUniversalMethodCall(Node\Expr\MethodCall $expr, string $receiver, string $method, array $def, bool $isVar = true): ?string { + if ($this->isWasiTarget()) { + if ($def['handler'] === 'php_fn') { + $this->assertWasiFunctionSupported($expr, $def['fn']); + } elseif ($def['handler'] === 'cpp_fn' + && str_starts_with($def['fn'], 'php::fn::') + ) { + $this->assertWasiFunctionSupported( + $expr, + substr($def['fn'], strlen('php::fn::')), + ); + } + } + if ($this->isNanoPolicyMode()) { + if ($def['handler'] === 'php_fn') { + $this->assertNanoFunctionSupported($expr, $def['fn']); + } elseif ($def['handler'] === 'cpp_fn' + && str_starts_with($def['fn'], 'php::fn::') + ) { + $this->assertNanoFunctionSupported( + $expr, + substr($def['fn'], strlen('php::fn::')), + ); + } + } $this->validateUniversalMethodArgs($expr, $method, $def, $isVar); return match ($def['handler']) { diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 8bd2301d..c87f1b4a 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -23,6 +23,7 @@ use TypePhp\Exception\SyntaxError; use TypePhp\Transform\PropertyHookLowering; use TypePhp\Transform\CompileTimeAttribute; use TypePhp\Transform\NativeClassAttributeLowering; +use TypePhp\Transform\NanoSyntaxValidationVisitor; use TypePhp\Transform\PrinterLowering; use TypePhp\Transform\ArrayableLowering; use TypePhp\Transform\ClassFieldSelection; @@ -324,6 +325,18 @@ class Preprocessor extends CompilerBase $info = pathinfo($cppFile); $ext = $this->getPlatform()->getObjectExtension(); + // Composer packages are immutable build inputs. Keep their objects in + // the project build directory instead of writing beside vendor sources. + if (isset($this->nanoRuntimeSources[$cppFile])) { + $separator = $this->getPlatform()->getPathSeparator(); + $objectDir = $this->buildDir . $separator . 'nano-objects'; + if (!is_dir($objectDir)) { + mkdir($objectDir, 0777, true); + } + return $objectDir . $separator . $info['filename'] . '-' + . substr(sha1($cppFile), 0, 12) . $ext; + } + // Keep the same path separator as cppFile $normalizedFile = str_replace('\\', '/', $cppFile); $normalizedMiscDir = str_replace('\\', '/', $this->getPhpxDir() . '/src/misc/'); @@ -400,6 +413,12 @@ class Preprocessor extends CompilerBase $traverser = new NodeTraverser(); $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); + if ($this->isNanoPolicyMode()) { + $traverser->addVisitor(new NanoSyntaxValidationVisitor( + fn (Node $node, string $message) => $this->fatalError($node, $message), + $this->isNanoMode(), + )); + } $traverser->addVisitor(new VoidCastValidationVisitor( fn (Node $node, string $message) => $this->fatalError($node, $message), )); diff --git a/src/Transform/NanoSyntaxValidationVisitor.php b/src/Transform/NanoSyntaxValidationVisitor.php new file mode 100644 index 00000000..0b0824df --- /dev/null +++ b/src/Transform/NanoSyntaxValidationVisitor.php @@ -0,0 +1,72 @@ + 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; + } +} diff --git a/src/Translator.php b/src/Translator.php index a1b30f77..d4415e2d 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -18,6 +18,9 @@ use TypePhp\Build\CompileOptions; use TypePhp\Build\FileScanner; use TypePhp\Build\NativeCommandOptionsTrait; use TypePhp\Build\NativeBuilder; +use TypePhp\Build\NanoBuildBackend; +use TypePhp\Build\NativeDependencyAuditor; +use TypePhp\Build\NanoSourceComposer; use TypePhp\Build\PrecompiledHeaderManager; use TypePhp\Build\SourcePipelineTrait; use TypePhp\Build\WasmInterfaceGenerator; @@ -54,6 +57,7 @@ use TypePhp\Transform\ConstructorLowering; use TypePhp\Transform\ConstantExpressionValidationVisitor; use TypePhp\Transform\PropertyHookLowering; use TypePhp\Transform\RuntimeAttributeFactoryLowering; +use TypePhp\Transform\NanoSyntaxValidationVisitor; use TypePhp\Transform\VoidCastValidationVisitor; use PhpParser\Modifiers; use PhpParser\Node; @@ -153,6 +157,12 @@ class Translator extends Preprocessor } unset($this->internalFunctions[self::ENTRY_FUNCTION]); $this->internalConstants = $this->loadInternalConstants(); + + // Apply this before --help/--version so informational output also + // honours an explicitly requested plain-text mode. + if ($this->climate->arguments->defined('no-color')) { + $this->climate->forceAnsiOff(); + } if ($this->climate->arguments->defined('help')) { $this->showUsage(); exit(0); @@ -162,11 +172,6 @@ class Translator extends Preprocessor exit(0); } - // Handle --no-color early so all subsequent output is colorless. - if ($this->climate->arguments->defined('no-color')) { - $this->climate->forceAnsiOff(); - } - // Detect the OS, the compiler, and (on Windows) the PHP lib files. $this->detectPlatform(); @@ -350,12 +355,34 @@ class Translator extends Preprocessor global $argv; $cmd = $argv[0]; + $printEntries = static function (array $entries) use ($climate): void { + $width = max(array_map( + static fn (array $entry): int => strlen($entry[0]), + $entries, + )); + foreach ($entries as [$syntax, $description]) { + $climate->tab()->out(str_pad($syntax, $width + 2) . $description); + } + }; + $climate->bold('USAGE:'); - $climate->tab()->out($cmd . ' [options]'); + $climate->tab()->out($cmd . ' [compilation options] [-- ]'); + $climate->tab()->out($cmd . ' [arguments]'); $climate->br(); $climate->bold('ARGUMENTS:'); - $climate->tab()->out(' Input PHP file/directory/YAML config to compile'); + $printEntries([ + ['', 'PHP file, source directory, YAML config, or native project.xml'], + ['-- ', 'Arguments passed to the program when used with --run'], + ]); + $climate->br(); + + $climate->bold('SUBCOMMANDS:'); + $printEntries([ + ['--gen-python-helper [--output-dir ]', 'Generate a Python namespace IDE helper'], + ['--convert-python-to-php ', 'Convert Python source to TypePHP source'], + ['--generate-completion=bash', 'Generate a Bash completion script'], + ]); $climate->br(); $climate->bold('EXAMPLES:'); @@ -364,42 +391,48 @@ class Translator extends Preprocessor $climate->tab()->out($cmd . ' project/config.yml -O2'); $climate->tab()->out($cmd . ' my-ext/ -O2 -o myapp -m ext'); $climate->tab()->out($cmd . ' app.php -r -O2 -- --flag1 value1'); + $climate->tab()->out($cmd . ' --gen-python-helper builtins'); + $climate->br(); + + $climate->bold('COMPILATION OPTIONS:'); + $printEntries([ + ['-O, --optimize ', 'Optimization level (0-3, default: 0)'], + ['--profile', 'Enable performance profiling (adds -lprofiler, forces recompile)'], + ['-d, --debug', 'Enable debug mode (disables optimizations and adds debug symbols)'], + ['-o, --output ', 'Output name or path (default: input basename)'], + ['-f, --force', 'Force recompilation and ignore the object cache'], + ['-m, --mode ', 'Build mode: bin, lib, or ext (default: bin)'], + ['-r, --run', 'Run the compiled binary after a successful build'], + ['-j, --job ', 'Number of parallel compilation jobs (default: 4)'], + ['--cxx-std ', 'C++ standard version (default: c++17)'], + ['--compiler ', 'C++ compiler command (for example /usr/bin/clang)'], + ['--march ', 'Target CPU instruction set (for example native or armv8-a)'], + ['--target-platform ', 'Cross-compilation target triple'], + ['--wasm[=browser|component]', 'Build WASI component (default) or browser output'], + ['--nano', 'Use the Nano policy and php-nano runtime outside Windows'], + ['--full-static', 'Link fully statically against the bundled SDK'], + ['--lto', 'Enable Link Time Optimization (-flto)'], + ['--no-literal-strings', 'Disable literal string optimization'], + ['--php-version ', 'Accepted PHP language version (8.4-8.5, default: 8.5)'], + ['--no-progress', 'Print one compilation line per file instead of a progress bar'], + ['--no-console', 'Hide the console window (Windows GUI applications only)'], + ['--sanitize ', 'Enable a sanitizer such as address or undefined'], + ['--build-dir ', 'Directory for generated C++ and build files'], + ['--dry', 'Generate C++ code without compiling or linking'], + ['-I, --include-path ', 'Add a C++ include directory (repeatable)'], + ['-D, --define ', 'Define a preprocessor macro (repeatable)'], + ['--format', 'Format generated C++ code with clang-format'], + ['-l, --link-lib ', 'Link against a library (repeatable)'], + ['-L, --link-path ', 'Add a library search path (repeatable)'], + ]); $climate->br(); - $climate->bold('OPTIONS:'); - $climate->tab()->out('-O Optimization level (0-3, default: 0)'); - $climate->tab()->out('--profile Enable performance profiling (adds -lprofiler, forces recompile)'); - $climate->tab()->out('-d, --debug Enable debug mode (auto-disable optimizations, add debug symbols)'); - $climate->tab()->out('-o, --output Output binary name (default: input basename)'); - $climate->tab()->out('-v, --version Show version'); - $climate->tab()->out('-h, --help Show this help message'); - $climate->tab()->out('-f, --force Force recompile phpx misc files (ignore cache)'); - $climate->tab()->out('-m, --mode Compilation mode: bin (binary), lib (shared library), or ext (PHP extension); default: bin'); - $climate->tab()->out('-r, --run Run the compiled binary after build'); - $climate->tab()->out('-j, --job Number of parallel compilation jobs (default: 4)'); - $climate->tab()->out('--cxx-std C++ standard version (c++17, c++20, etc., default: c++17)'); - $climate->tab()->out('--compiler C++ compiler command to use (e.g. --compiler=/usr/bin/clang)'); - $climate->tab()->out('--march Target CPU instruction set (e.g. native, x86-64-v3, armv8-a)'); - $climate->tab()->out('--target-platform Cross-compilation target triple (e.g. aarch64-linux-gnu)'); - $climate->tab()->out('--wasm[=profile] Build WASI component (default) or browser output'); - $climate->tab()->out('--gen-python-helper [--output-dir ] Generate a Python namespace IDE helper'); - $climate->tab()->out('--convert-python-to-php Convert Python source to TypePHP source'); - $climate->tab()->out('--generate-completion=bash Generate Bash completion script'); - $climate->tab()->out('--lto Enable Link Time Optimization (-flto)'); - $climate->tab()->out('--no-literal-strings Disable literal strings optimization'); - $climate->tab()->out('--php-version PHP language version to accept (8.4-8.5, default: 8.5)'); - $climate->tab()->out('--no-progress Disable progress bar, output per-file compilation progress line by line'); - $climate->tab()->out('--no-console Hide console window (Windows only, GUI application)'); - $climate->tab()->out('--no-color Disable ANSI color output'); - $climate->tab()->out('--sanitize Enable sanitizers (address, undefined, etc.)'); - $climate->tab()->out('--build-dir Specify build directory for generated C++ code (default: /build)'); - $climate->tab()->out('--dry Dry run: only generate C++ code, skip compilation and linking'); - $climate->tab()->out('-I, --include-path Add an additional C++ include directory (repeatable)'); - $climate->tab()->out('-D, --define Define a preprocessor macro (repeatable, e.g. -D FOO=bar)'); - $climate->tab()->out('--format Enable clang-format code formatting (disabled by default)'); - $climate->tab()->out('-l, --link-lib Link against a library (repeatable, e.g. -lcurl)'); - $climate->tab()->out('-L, --link-path Add a library search path (repeatable, e.g. -L/usr/local/lib)'); - $climate->tab()->out('--full-static Link fully statically against the bundled SDK (phpx/full-static/sdk)'); + $climate->bold('GENERAL OPTIONS:'); + $printEntries([ + ['-h, --help', 'Show this help message'], + ['-v, --version', 'Show the compiler version'], + ['--no-color', 'Disable ANSI color output'], + ]); $climate->br(); } @@ -411,6 +444,17 @@ class Translator extends Preprocessor { $this->applyPhpVersionCommandLineArgument(); + // The Nano syntax policy is platform-independent. On non-Windows hosts + // only the runtime source and link inputs change; argument parsing, + // translation, compilation scheduling and diagnostics remain shared. + if ($this->climate->arguments->defined('nano')) { + $this->nanoPolicyMode = true; + if (NanoBuildBackend::composesRuntimeSources(PHP_OS_FAMILY)) { + $this->nanoMode = true; + $this->noLiteralStrings = true; + } + } + // Optimization level if ($this->climate->arguments->defined('optimize')) { $this->optimizeLevel = $this->climate->arguments->get('optimize'); @@ -540,6 +584,21 @@ class Translator extends Preprocessor if ($this->hasRepeatableArgvFlag(['-L', '--link-path'])) { $this->linkPaths = $this->parseRepeatableArgv(['-L', '--link-path']); } + + if ($this->isNanoMode()) { + if (!$this->isBuildModeBin()) { + $this->error('--nano source composition only supports binary mode (-m bin)'); + } + if ($this->cxxStd !== 'c++17') { + $this->error('--nano requires the C++17 language standard'); + } + if ($this->fullStatic) { + $this->error('--nano already composes its runtime sources; --full-static is not applicable'); + } + if ($this->linkLibs !== [] || $this->linkPaths !== []) { + $this->error('--nano does not permit external link libraries or library search paths'); + } + } } /** Apply this option early because YAML source conditions depend on it. */ @@ -884,7 +943,9 @@ class Translator extends Preprocessor // Embedded binaries populate the CLI script fields in $_SERVER at // request startup, even when the source does not reference $_SERVER. - if ($this->isBuildModeBin() && !$this->hasGlobalVar('_SERVER')) { + if (!$this->isNanoMode() + && $this->isBuildModeBin() + && !$this->hasGlobalVar('_SERVER')) { $this->addGlobalVar('_SERVER', Type::ARRAY); } @@ -958,6 +1019,41 @@ class Translator extends Preprocessor } } + /** Generate the VM-less executable entry used by the Composer php-nano runtime. */ + public function genNanoEntrypoint(): string + { + if (!$this->isNanoMode()) { + throw new \LogicException('Nano entry generation requires Nano mode'); + } + if (!$this->hasFunction(self::ENTRY_FUNCTION)) { + throw new \RuntimeException('A Nano executable must define main()'); + } + + $entry = $this->getFunction(self::ENTRY_FUNCTION); + if (count($entry->argInfoList) !== 0 && count($entry->argInfoList) !== 2) { + throw new \RuntimeException( + 'Nano main() accepts either no parameters or (int $argc, array $argv)' + ); + } + if (!in_array($entry->returnType, [Type::INT, Type::VOID], true)) { + throw new \RuntimeException('Nano main() must return int or void'); + } + $file = $this->getBuildDir() . '/nano-entry-' . $this->targetName . '.cc'; + $call = count($entry->argInfoList) === 2 + ? 'php_main(php::global("argc").toInt(), php::global("argv").toArray())' + : 'php_main()'; + $entryCall = $entry->returnType === Type::VOID + ? $call . ';' . PHP_EOL . ' return 0;' + : 'return static_cast(' . $call . ');'; + $code = '#include targetName . '_func_decl.h>' . PHP_EOL . PHP_EOL; + $code .= 'extern "C" int typephp_nano_project_main() {' . PHP_EOL; + $code .= ' ' . $entryCall . PHP_EOL; + $code .= '}' . PHP_EOL; + + $this->writeFile($file, $code); + return $file; + } + /** @return array{declarations: string, registration: string} */ private function genTraitMetadataCode(): array { @@ -1027,7 +1123,7 @@ class Translator extends Preprocessor // Keep out of the shared PCH dependency set used by every source. $code .= '#include ' . PHP_EOL; - if ($this->isBuildModeEmbed()) { + if ($this->isBuildModeEmbed() && !$this->isNanoMode()) { $code .= '#include ' . PHP_EOL; } @@ -1036,7 +1132,10 @@ class Translator extends Preprocessor $code .= 'extern "C" void save_ps_args(int, char **) {}' . PHP_EOL; } - if ($this->isBuildModeBin() && !$this->isWasiTarget() && !$this->isIosTarget()) { + if (!$this->isNanoMode() + && $this->isBuildModeBin() + && !$this->isWasiTarget() + && !$this->isIosTarget()) { $cliHeaders = [ '#include "php_cli_process_title.h"', '#include "php_cli_process_title_arginfo.h"', @@ -1235,7 +1334,10 @@ CODE; $code .= "// clang-format off\n"; $code .= "static const zend_function_entry ext_functions[] = {\n"; - if ($this->isBuildModeBin() && !$this->isWasiTarget() && !$this->isIosTarget()) { + if (!$this->isNanoMode() + && $this->isBuildModeBin() + && !$this->isWasiTarget() + && !$this->isIosTarget()) { $code .= $this->getIndent() . "PHP_FE(cli_set_process_title, arginfo_cli_set_process_title)\n"; $code .= $this->getIndent() . "PHP_FE(cli_get_process_title, arginfo_cli_get_process_title)\n"; } @@ -1294,7 +1396,7 @@ CODE; } $code .= $this->getIndent() . 'return FAILURE;' . PHP_EOL; $code .= '}' . PHP_EOL; - if (!$this->isWasiTarget()) { + if (!$this->isWasiTarget() && !$this->isNanoMode()) { $code .= 'typephp_register_fiber_generator_class();' . PHP_EOL; } @@ -1341,7 +1443,7 @@ CODE; $code .= 'for (auto &slot : ' . self::PREFIX . self::PERSISTENT_PROP_MAP . ') {' . PHP_EOL; $code .= $this->getIndent() . 'php::resetPersistentCache(slot);' . PHP_EOL; $code .= '}' . PHP_EOL; - if (!$this->isWasiTarget()) { + if (!$this->isWasiTarget() && !$this->isNanoMode()) { $code .= 'typephp_unregister_fiber_generator_class();' . PHP_EOL; } if ($traitMetadata['registration'] !== '') { @@ -1503,41 +1605,68 @@ CODE; // rinit begin $code .= 'PHP_RINIT_FUNCTION(' . $moduleName . ') {' . PHP_EOL; $code .= 'if (UNEXPECTED(php_request_cache != nullptr)) {' . PHP_EOL; - $code .= $this->getIndent() . 'php_error_docref(nullptr, E_WARNING, "TypePHP request cache is already initialized");' . PHP_EOL; + $code .= $this->getIndent() . ($this->nanoMode + ? 'zend_error(E_WARNING, "TypePHP request cache is already initialized");' + : 'php_error_docref(nullptr, E_WARNING, "TypePHP request cache is already initialized");') . PHP_EOL; $code .= $this->getIndent() . 'return FAILURE;' . PHP_EOL; $code .= '}' . PHP_EOL; $code .= 'php_request_cache = new (std::nothrow) php_request_cache_storage{};' . PHP_EOL; $code .= 'if (UNEXPECTED(php_request_cache == nullptr)) {' . PHP_EOL; - $code .= $this->getIndent() . 'php_error_docref(nullptr, E_WARNING, "Unable to allocate TypePHP request cache");' . PHP_EOL; + $code .= $this->getIndent() . ($this->nanoMode + ? 'zend_error(E_WARNING, "Unable to allocate TypePHP request cache");' + : 'php_error_docref(nullptr, E_WARNING, "Unable to allocate TypePHP request cache");') . PHP_EOL; $code .= $this->getIndent() . 'return FAILURE;' . PHP_EOL; $code .= '}' . PHP_EOL; $code .= 'php::request_init();' . PHP_EOL; + if ($this->isNanoPolicyMode() && !$this->isNanoMode()) { + // The full Windows runtime still contains standard/process modules. + // Remove command functions from Zend's table after every module has + // started so variable functions and call_user_func cannot bypass + // the compile-time named-call check. + $code .= 'zend_disable_functions(' + . $this->genCharPtr($this->getNanoPolicyDisabledFunctionList(), true) + . ');' . PHP_EOL; + } $code .= 'module_init();' . PHP_EOL; - if ($this->isBuildModeBin()) { + if ($this->isBuildModeBin() && !$this->isNanoMode()) { $entryFunction = $this->symbols->function(self::ENTRY_FUNCTION); - // FunctionDef::sourceFile comes from loadFile()'s realpath(), so the - // CLI script fields always identify main()'s canonical absolute file. - $entryFile = $entryFunction->sourceFile; - $entryFileArg = $this->genCharPtr($entryFile, true); - $entryLineOffset = max(0, $entryFunction->startLine - 1); - if (count($entryFunction->argInfoList) == 2) { - $entryScript = 'global $argc, $argv; main($argc, $argv);'; + if ($this->isNanoPolicyMode()) { + // Windows keeps the complete PHP/PHPX DLL runtime, but a Nano + // executable still enters generated code without ZendVM eval. + $code .= $this->registerServerEnvironment($entryFunction->sourceFile); + $entryCall = count($entryFunction->argInfoList) === 2 + ? 'php_main(php::global("argc").toInt(), php::global("argv").toArray());' + : 'php_main();'; + $code .= 'try {' . PHP_EOL; + $code .= $this->getIndent(2) . $entryCall . PHP_EOL; + $code .= '} catch (zend_object *) {' . PHP_EOL; + $code .= $this->getIndent(2) . 'return FAILURE;' . PHP_EOL; + $code .= '}' . PHP_EOL; } else { - $entryScript = 'main();'; - } + // FunctionDef::sourceFile comes from loadFile()'s realpath(), so the + // CLI script fields always identify main()'s canonical absolute file. + $entryFile = $entryFunction->sourceFile; + $entryFileArg = $this->genCharPtr($entryFile, true); + $entryLineOffset = max(0, $entryFunction->startLine - 1); + if (count($entryFunction->argInfoList) == 2) { + $entryScript = 'global $argc, $argv; main($argc, $argv);'; + } else { + $entryScript = 'main();'; + } - $entryScriptArg = $this->genCharPtr($entryScript, true); - if ($entryLineOffset > 0) { - // entryLineOffset is main()'s source start line minus one. The - // generated std::string(N, '\n') supplies N padding newlines at - // runtime, so the eval() entry call is reported on main()'s - // original PHP source line. Constructing the padding at runtime - // avoids embedding hundreds of escaped newlines in the C++ file. - $entryScriptArg = 'std::string(' . $entryLineOffset . ", '\\n') + " . $entryScriptArg; - } + $entryScriptArg = $this->genCharPtr($entryScript, true); + if ($entryLineOffset > 0) { + // entryLineOffset is main()'s source start line minus one. The + // generated std::string(N, '\n') supplies N padding newlines at + // runtime, so the eval() entry call is reported on main()'s + // original PHP source line. Constructing the padding at runtime + // avoids embedding hundreds of escaped newlines in the C++ file. + $entryScriptArg = 'std::string(' . $entryLineOffset . ", '\\n') + " . $entryScriptArg; + } - $code .= 'php::eval(' . $entryScriptArg . ', ' . $entryFileArg . ');' . PHP_EOL; + $code .= 'php::eval(' . $entryScriptArg . ', ' . $entryFileArg . ');' . PHP_EOL; + } } $code .= 'return SUCCESS;' . PHP_EOL; @@ -1586,7 +1715,7 @@ CODE; if ($this->isBuildModeExt()) { $code .= "ZEND_GET_MODULE({$moduleName});\n"; $code .= '} // namespace ' . $projectNamespace . PHP_EOL; - } elseif ($this->isBuildModeEmbed()) { + } elseif ($this->isBuildModeEmbed() && !$this->isNanoMode()) { $code .= '} // namespace ' . $projectNamespace . PHP_EOL . PHP_EOL; $code .= 'TYPEPHP_EMBED_GET_MODULE_FUNCTION(' . $this->targetName . ') {' . PHP_EOL; $code .= $this->getIndent() . 'return &' . $projectNamespace . '::' . $moduleName . '_module_entry;' . PHP_EOL; @@ -1751,12 +1880,16 @@ CODE; { $isCacheableMiscFile = $this->isPhpxMiscFile($cppFile) && !$this->isProjectRuntimeEntryFile($cppFile); + $isNanoRuntimeSource = isset($this->nanoRuntimeSources[$cppFile]); if ($isCacheableMiscFile && $this->hasMiscObjectFileCache($cppFile)) { if (!$parallel) { $this->climate->darkGray('[cache] skip: ' . $cppFile); } return; } + if ($isNanoRuntimeSource && $this->hasNanoObjectFileCache($cppFile, $objectFile)) { + return; + } if ($isCacheableMiscFile) { $this->invalidateMiscObjectCache($objectFile); @@ -1780,6 +1913,28 @@ CODE; if ($isCacheableMiscFile) { $this->writeMiscObjectCacheMetadata($cppFile, $objectFile); } + if ($isNanoRuntimeSource) { + $this->writeMiscObjectCacheMetadata($cppFile, $objectFile); + } + } + + private function hasNanoObjectFileCache(string $sourceFile, string $objectFile): bool + { + if ($this->climate->arguments->defined('force') || !is_file($objectFile)) { + return false; + } + $metadataFile = $this->getMiscObjectCacheMetadataFile($objectFile); + $cachedKey = is_file($metadataFile) ? file_get_contents($metadataFile) : false; + if ($cachedKey === false + || trim($cachedKey) !== $this->getMiscObjectCacheKey($sourceFile, $objectFile)) { + return false; + } + $objectMtime = filemtime($objectFile); + $sourceMtime = filemtime($sourceFile); + return $objectMtime !== false + && $sourceMtime !== false + && $objectMtime > $sourceMtime + && $objectMtime >= $this->nanoRuntimeHeaderMtime; } protected function getSourceCompileCommandOptions(string $sourceFile, ?string $language): CompileOptions @@ -1806,8 +1961,10 @@ CODE; { $job = $this->maxJob; + if ($this->isNanoMode()) { + $sourceFiles = $this->composeNanoRuntimeSources($sourceFiles); // The embed build needs the main function and the CLI's built-in function definitions. - if ($this->isBuildModeEmbed()) { + } elseif ($this->isBuildModeEmbed()) { $runtimeSource = $this->getPhpxDir() . '/src/misc/typephp_runtime.cc'; // PHPX 2.6.3 keeps the common runtime in typephp_main.cc. Newer // PHPX versions split it out so the object can be shared across @@ -1818,12 +1975,17 @@ CODE; $sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_main.cc'; } - if ($this->isBuildModeBin() && !$this->isWasiTarget() && !$this->isIosTarget()) { + if (!$this->isNanoMode() + && $this->isBuildModeBin() + && !$this->isWasiTarget() + && !$this->isIosTarget()) { $sourceFiles[] = $this->getPhpxDir() . '/src/misc/php_cli_process_title.c'; $sourceFiles[] = $this->getPhpxDir() . '/src/misc/ps_title.c'; } - $this->preparePhpXPrecompiledHeader(); + if (!$this->isNanoMode()) { + $this->preparePhpXPrecompiledHeader(); + } // Windows: compile the resource file (icon, version info, etc.) $this->compileResourceFile(); @@ -1836,6 +1998,47 @@ CODE; return $this->compileWithPcntl($sourceFiles, $job); } + /** @param list $generatedSources @return list */ + private function composeNanoRuntimeSources(array $generatedSources): array + { + $composition = (new NanoSourceComposer())->compose( + $this->getBuildDir(), + $this->targetName, + true, + ); + $this->nanoRuntimeIncludePaths = $composition['includeDirs']; + $this->nanoRuntimeSources = array_fill_keys($composition['packageSources'], true); + $this->nanoRuntimeHeaderMtime = $this->latestNanoHeaderMtime( + $this->nanoRuntimeIncludePaths, + ); + + return array_values(array_unique([ + ...$generatedSources, + ...$composition['packageSources'], + $composition['registry'], + ])); + } + + /** @param list $directories */ + private function latestNanoHeaderMtime(array $directories): int + { + $latest = 0; + foreach ($directories as $directory) { + if (!is_dir($directory)) { + continue; + } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + ); + foreach ($iterator as $file) { + if ($file->isFile()) { + $latest = max($latest, $file->getMTime()); + } + } + } + return $latest; + } + protected function preparePhpXPrecompiledHeader(): void { $backend = $this->getCompilerBackend(); @@ -2064,11 +2267,69 @@ CODE; $this->error($buildError); } + if ($this->isNanoMode()) { + $this->auditNanoArtifact($objectFiles, $targetFile); + } + $this->climate->green('Build successful: ' . $targetFile); return $targetFile; } + /** @param list $objectFiles */ + private function auditNanoArtifact(array $objectFiles, string $targetFile): void + { + $this->climate->info('Auditing Nano runtime dependencies'); + $auditor = new NativeDependencyAuditor(); + if ($this->isWasiTarget()) { + $nm = getenv('TYPEPHP_WASI_NM'); + if (!is_string($nm) || $nm === '') { + $compiler = $this->getCompilerBackend()->getCompilerCommand(); + $candidate = dirname($compiler) . DIRECTORY_SEPARATOR . 'llvm-nm'; + $nm = is_executable($candidate) ? $candidate : 'llvm-nm'; + } + $auditor->assertUndefinedSymbols( + 'wasip2', + $this->captureNativeCommand([$nm, '--undefined-only', ...$objectFiles]), + ); + return; + } + $auditor->assertUndefinedSymbols( + 'native', + $this->captureNativeCommand(['nm', '-u', ...$objectFiles]), + ); + $auditor->assertUndefinedSymbols( + 'native', + $this->captureNativeCommand(['nm', '-u', $targetFile]), + ); + } + + /** @param list $command */ + private function captureNativeCommand(array $command): string + { + $process = proc_open( + $command, + [STDIN, ['pipe', 'w'], ['pipe', 'w']], + $pipes, + getcwd() ?: $this->rootPath, + ); + if (!is_resource($process)) { + throw new \RuntimeException("Unable to start Nano 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( + "Nano audit command failed with status {$status}" + . ($stderr === '' ? '' : ": {$stderr}"), + ); + } + return (string) $stdout; + } + protected function getNativeBuilder(): NativeBuilder { return $this->nativeBuilder ??= new NativeBuilder($this->getCompilerBackend()); @@ -2338,7 +2599,28 @@ CODE; public function genIncludeHeaderFiles(): string { - $headers = array_merge($this->globalHeaders, [ + $globalHeaders = $this->isNanoMode() + ? [ + 'cstring', + 'phpx.h', + 'phpx_helper.h', + 'typephp_helper.h', + 'phpx_big_int.h', + 'phpx_big_float.h', + 'phpx_decimal.h', + 'std/core.h', + 'std/ctype.h', + 'std/array.h', + 'std/string.h', + 'std/math.h', + 'std/json.h', + 'std/datetime.h', + 'std/hash.h', + 'std/misc.h', + 'std/random.h', + ] + : $this->globalHeaders; + $headers = array_merge($globalHeaders, [ "php_{$this->targetName}_func_decl.h", "php_{$this->targetName}_data_decl.h", ], $this->localHeaders); @@ -3103,6 +3385,12 @@ CODE; $ast = $this->parser->parse($phpCode); $traverser = new NodeTraverser(); $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); + if ($this->isNanoPolicyMode()) { + $traverser->addVisitor(new NanoSyntaxValidationVisitor( + fn (Node $node, string $message) => $this->fatalError($node, $message), + $this->isNanoMode(), + )); + } $traverser->addVisitor(new VoidCastValidationVisitor( fn (Node $node, string $message) => $this->fatalError($node, $message), )); @@ -4864,7 +5152,7 @@ CODE; } else { $isEntryFunction = $this->hasFunction(self::ENTRY_FUNCTION) && $functionDef === $this->getFunction(self::ENTRY_FUNCTION); - if ($this->isBuildModeBin() && $isEntryFunction) { + if ($this->isBuildModeBin() && !$this->isNanoMode() && $isEntryFunction) { // $_SERVER initialization must live inside the main entry function so // the runtime environment and superglobal context are fully ready // before it is accessed. @@ -5054,6 +5342,7 @@ CODE; } } $this->initializeImmutableFunctionContext(); + $this->prepareReferenceCaptureDegradations($v->stmts, true); if ($this->functionDef->generator) { try { @@ -5138,11 +5427,15 @@ CODE; // Native classes may be discovered after an earlier declaration was // normalized; the final ABI must use the precise native pointer type, // not a stale php::Object spelling cached during preprocessing. - $functionDeclCode .= $this->getNativeMethodParameterDeclarations($this->functionDef) . ')'; + $functionDeclCode .= $this->getNativeMethodParameterDeclarations( + $this->functionDef, + useDegradedArgumentNames: true, + ) . ')'; $code = $functionDeclCode . ' {' . PHP_EOL; $this->indentLevel++; - $preamble = $this->genScopeVarDecl(); + $preamble = $this->genDegradedArgumentLocals(); + $preamble .= $this->genScopeVarDecl(); $preamble .= $this->genNativeObjectParameterChecks($this->functionDef); // Runtime union/nullable parameter type checks foreach ($this->functionDef->argInfoList as $i => $argInfo) { diff --git a/src/compiler.php b/src/compiler.php index 31111069..09fc76d8 100644 --- a/src/compiler.php +++ b/src/compiler.php @@ -3,6 +3,8 @@ use TypePhp\Translator; use TypePhp\Build\WasiToolchain; use TypePhp\Build\WasiProjectConfig; use TypePhp\Build\PhpxLocator; +use TypePhp\Build\NativeSourceProjectBuilder; +use TypePhp\Build\NativeSourceProjectConfig; use TypePhp\PythonTools\Command as PythonToolsCommand; use TypePhp\Cli\CompletionCommand; @@ -42,6 +44,11 @@ function main(int $argc, array $argv): void return; } + if (shouldCompileNativeSourceProject($argv)) { + compileNativeSourceProject($argv); + return; + } + if (getenv('TYPEPHP_WASM_INTERNAL_COMPILE') !== '1' && shouldCompileWasm($argv)) { compileWasmProgram($argv); return; @@ -113,6 +120,84 @@ function main(int $argc, array $argv): void } } +function shouldCompileNativeSourceProject(array $argv): bool +{ + foreach (array_slice($argv, 1) as $argument) { + if ($argument === '' || $argument[0] === '-') { + continue; + } + $path = $argument; + if ($path[0] !== '/' && preg_match('/^[A-Za-z]:[\\\\\/]/', $path) !== 1) { + $path = getcwd() . DIRECTORY_SEPARATOR . $path; + } + return NativeSourceProjectConfig::isNativeProject($path); + } + return false; +} + +function compileNativeSourceProject(array $argv): void +{ + $input = null; + $buildDir = null; + $run = false; + $arguments = array_slice($argv, 1); + for ($i = 0, $count = count($arguments); $i < $count; ++$i) { + $argument = $arguments[$i]; + if ($argument === '--run' || $argument === '-r') { + $run = true; + continue; + } + if ($argument === '--build-dir') { + if (!isset($arguments[$i + 1]) || $arguments[$i + 1] === '') { + fwrite(STDERR, "Option --build-dir requires a directory\n"); + exit(1); + } + $buildDir = $arguments[++$i]; + continue; + } + if (str_starts_with($argument, '--build-dir=')) { + $buildDir = substr($argument, strlen('--build-dir=')); + if ($buildDir === '') { + fwrite(STDERR, "Option --build-dir requires a directory\n"); + exit(1); + } + continue; + } + if ($argument !== '' && $argument[0] === '-') { + fwrite(STDERR, "Unsupported native option: {$argument}\n"); + exit(1); + } + if ($input !== null) { + fwrite(STDERR, "Native mode accepts exactly one project.xml\n"); + exit(1); + } + $input = $argument; + } + + if ($input === null) { + fwrite(STDERR, "Usage: vendor/bin/tpc project.xml [--build-dir DIR] [--run]\n"); + exit(1); + } + + try { + $project = NativeSourceProjectConfig::load($input, $buildDir); + $builder = new NativeSourceProjectBuilder(); + $result = $builder->build($project); + fwrite( + STDOUT, + "Native source build completed: {$result['sourceCount']} source file(s), " + . "{$result['compiledCount']} compiled, " + . "output {$result['output']}\n" + ); + if ($run) { + $builder->runOutput($project); + } + } catch (RuntimeException $exception) { + fwrite(STDERR, "Native source build failed: {$exception->getMessage()}\n"); + exit(1); + } +} + /** * Build a self-contained WASI 0.2 command component through the public CLI. * The lower-level build scripts are implementation details and are not part of @@ -123,12 +208,17 @@ function compileWasmProgram(array $argv): void $input = null; $buildDir = null; $profile = null; + $nano = false; $arguments = array_slice($argv, 1); for ($i = 0, $count = count($arguments); $i < $count; $i++) { $argument = $arguments[$i]; if ($argument === '--wasm') { continue; } + if ($argument === '--nano') { + $nano = true; + continue; + } if (str_starts_with($argument, '--wasm=')) { $value = substr($argument, strlen('--wasm=')); if ($value === '') { @@ -188,7 +278,8 @@ function compileWasmProgram(array $argv): void exit(1); } - $builder = dirname(__DIR__) . '/wasm/build-program.sh'; + $builder = dirname(__DIR__) . '/wasm/' + . ($nano ? 'build-nano-program.sh' : 'build-program.sh'); if (!is_executable($builder)) { fwrite(STDERR, "TypePHP WASI builder is not executable: {$builder}\n"); exit(1); @@ -237,6 +328,7 @@ function compileWasmProgram(array $argv): void $environment['TYPEPHP_WASM_MODE'] = $project->mode; $environment['TYPEPHP_WASM_PACKAGE'] = $project->package; $environment['TYPEPHP_WASM_WORLD'] = $project->world; + $environment['TYPEPHP_WASM_NANO'] = $nano ? '1' : '0'; $compilerExecutable = realpath($argv[0]); if ($compilerExecutable === false || !is_executable($compilerExecutable)) { fwrite(STDERR, "Unable to resolve the current TypePHP compiler executable: {$argv[0]}\n"); diff --git a/tests/compiler/closure/reference-capture-auto-degrade.phpt b/tests/compiler/closure/reference-capture-auto-degrade.phpt new file mode 100644 index 00000000..e49c1ff7 --- /dev/null +++ b/tests/compiler/closure/reference-capture-auto-degrade.phpt @@ -0,0 +1,69 @@ +--TEST-- +Closure reference captures automatically degrade inferred locals to var storage +--FILE-- +replace([])()); +} +?> +--EXPECT-- +int(1) +int(2) +array(1) { + [0]=> + string(5) "after" +} +string(8) "replaced" +array(1) { + [0]=> + string(9) "parameter" +} +string(6) "method" diff --git a/wasm/build-nano-program.sh b/wasm/build-nano-program.sh new file mode 100755 index 00000000..fac7eed3 --- /dev/null +++ b/wasm/build-nano-program.sh @@ -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 " >&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