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