feat(compiler): add native source project compilation with closure reference capture support

- Add native source project compilation workflow with project.xml configuration
- Implement closure reference capture analysis and type degradation for native objects
- Add WASI and nano mode function support checks with comprehensive API exclusions
- Introduce Composer native package discovery and validation system
- Add build scripts for WASI nano program compilation with browser module support
- Implement closure generator enhancements for reference capture handling
- Add CLI usage tests and compiler base API validations
- Integrate php-nano runtime dependencies and native package management
- Add type degradation analysis for closure use variables in generators and functions
master
韩天峰 4 weeks ago
parent 2fe09697fa
commit b67e40feee
  1. 32
      README-CN.md
  2. 38
      README.md
  3. 2
      completions/tpc.bash
  4. 2
      composer.json
  5. 16
      docs/en/INCOMPATIBLE_PHP_FEATURES.md
  6. 14
      docs/zh-cn/INCOMPATIBLE_PHP_FEATURES.md
  7. 9
      docs/zh-cn/TYPED_NATIVE_REFERENCE_DESIGN.md
  8. 1
      examples/hello.php
  9. 4
      phpunit/code/closure/use-reference-capture.php
  10. 13
      phpunit/code/fixed-reference-native-object.php
  11. 7
      phpunit/code/nano-file-stream-method.php
  12. 6
      phpunit/code/nano-unavailable-host-function.php
  13. 6
      phpunit/code/nano-unavailable-string-method.php
  14. 18
      phpunit/code/reference-capture-auto-degrade.php
  15. 11
      phpunit/code/universal-method-high-precision-to-bool.php
  16. 6
      phpunit/code/wasi-unavailable-host-function.php
  17. 44
      phpunit/src/Analysis/ReferenceCaptureAnalyzerTest.php
  18. 109
      phpunit/src/Build/ComposerNativePackageTest.php
  19. 31
      phpunit/src/Build/NanoBuildBackendTest.php
  20. 132
      phpunit/src/Build/NativeDependencyAuditorTest.php
  21. 72
      phpunit/src/Build/NativeSourceProjectConfigTest.php
  22. 54
      phpunit/src/CliUsageTest.php
  23. 10
      phpunit/src/ClosureTest.php
  24. 12
      phpunit/src/CompilerBaseApiTest.php
  25. 29
      phpunit/src/EntryScriptCodegenTest.php
  26. 65
      phpunit/src/FixedReferenceStorageTest.php
  27. 11
      phpunit/src/LocalClosureCodegenTest.php
  28. 271
      phpunit/src/NanoCapabilityPolicyTest.php
  29. 142
      phpunit/src/Transform/NanoSyntaxValidationVisitorTest.php
  30. 18
      phpunit/src/UniversalMethodCallTest.php
  31. 9
      phpunit/src/VarIntTypesTest.php
  32. 85
      src/Analysis/ReferenceCaptureAnalyzer.php
  33. 14
      src/Backend/GccLikeBackend.php
  34. 228
      src/Build/ComposerNativePackage.php
  35. 23
      src/Build/NanoBuildBackend.php
  36. 113
      src/Build/NanoSourceComposer.php
  37. 15
      src/Build/NativeBuildConfigurationTrait.php
  38. 30
      src/Build/NativeCommandOptionsTrait.php
  39. 105
      src/Build/NativeDependencyAuditor.php
  40. 481
      src/Build/NativeSourceProjectBuilder.php
  41. 156
      src/Build/NativeSourceProjectConfig.php
  42. 84
      src/Build/SourcePipelineTrait.php
  43. 169
      src/CompilerBase.php
  44. 73
      src/Context/CompilationStateTrait.php
  45. 3
      src/Context/FunctionContext.php
  46. 15
      src/Generator/ClosureGenerator.php
  47. 2
      src/Generator/FiberGenerator.php
  48. 6
      src/Metadata/Constants.php
  49. 7
      src/NativeClass/NativeClassSupportTrait.php
  50. 15
      src/Parser/AssignOpTrait.php
  51. 7
      src/Parser/FunctionCallTrait.php
  52. 2
      src/Parser/MethodCallTrait.php
  53. 2
      src/Parser/TypeConversionTrait.php
  54. 30
      src/Parser/UniversalMethodCall.php
  55. 19
      src/Preprocessor.php
  56. 72
      src/Transform/NanoSyntaxValidationVisitor.php
  57. 407
      src/Translator.php
  58. 94
      src/compiler.php
  59. 69
      tests/compiler/closure/reference-capture-auto-degrade.phpt
  60. 84
      wasm/build-nano-program.sh

@ -228,6 +228,38 @@ string(16) "Linux ..."
> `main(int $argc, array $argv)` 以接收命令行参数,且必须返回 `void`。全局作用域 > `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` 选择: TypePHP 支持三种构建模式,通过 `-m` / `--mode` 选择:

@ -254,6 +254,44 @@ string(16) "Linux ..."
> arguments, and must return `void`. Top-level executable statements are not > arguments, and must return `void`. Top-level executable statements are not
> allowed; executable code belongs in a function or method. > 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 ## Compilation Modes
TypePHP supports three build modes, selected with `-m` / `--mode`: TypePHP supports three build modes, selected with `-m` / `--mode`:

@ -144,7 +144,7 @@ _typephp_tpc()
return 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 return
;; ;;
esac esac

@ -11,6 +11,7 @@
} }
], ],
"bin": [ "bin": [
"bin/tpc",
"bin/tpc.php" "bin/tpc.php"
], ],
"require": { "require": {
@ -22,6 +23,7 @@
"symfony/var-dumper": "^8.0", "symfony/var-dumper": "^8.0",
"symfony/yaml": "^8.0", "symfony/yaml": "^8.0",
"swoole/phpx": "~2.8.0", "swoole/phpx": "~2.8.0",
"swoole/php-nano": "^8.6@dev",
"ajaxray/ansikit": "^0.3", "ajaxray/ansikit": "^0.3",
"ext-dom": "*" "ext-dom": "*"
}, },

@ -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 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 statically resolved TypePHP call also uses `T&` without boxing or allocating a
Zend reference. Rebinding, conditional/loop-local first binding, `unset`, Zend reference. Rebinding, conditional/loop-local first binding, `unset`,
by-reference Closure capture, returning the local by reference, or storing the returning the local by reference, or storing the reference in a
reference in a property/array/global is rejected because the C++ reference may property/array/global is rejected because the C++ reference may not escape or
not escape or change its target. 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()`. - Dynamic calls and Closure calls still require explicit `std::ref()` / `toRef()`.
TypePHP creates a call-scoped Zend reference, validates its type on write-back, 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 and reports an error if dynamic code retains it beyond the call. Code requiring
unrestricted PHP reference identity should initialize the local with unrestricted PHP reference identity should initialize the local with
`std::any()` and use the existing `php::Var`/`php::Ref` path. `std::any()` and use the existing `php::Var`/`php::Ref` path.
- Fixed object, resource/stream, high-precision, Native/typed-object, Box, and - Ordinary object, resource/stream, and high-precision locals also degrade to
`std`-container locals cannot be referenced. These values already have `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 handle/reference-like semantics, while rebinding their statically typed local
slot would weaken the type system. Typed object/static properties remain slot would weaken the type system. Typed object/static properties remain
reference-capable because Zend attaches property type sources; PHP array reference-capable because Zend attaches property type sources; PHP array

@ -52,16 +52,18 @@
`$alias =& $value` 这类函数顶层的一次性绑定会生成 C++ `T&`;静态可解析的 TypePHP `$alias =& $value` 这类函数顶层的一次性绑定会生成 C++ `T&`;静态可解析的 TypePHP
调用中,精确的 `int/string/float/bool/array &$arg` 同样直接传递 `T&`,不装箱也不 调用中,精确的 `int/string/float/bool/array &$arg` 同样直接传递 `T&`,不装箱也不
创建 Zend reference。由于 C++ 引用不能改绑或逃逸,条件/循环内首次绑定、重新绑定、 创建 Zend reference。由于 C++ 引用不能改绑或逃逸,条件/循环内首次绑定、重新绑定、
`unset`、Closure 按引用捕获、按引用返回局部变量,以及把引用保存到属性、数组或全局 `unset`、按引用返回局部变量,以及把引用保存到属性、数组或全局槽位均会在编译期拒绝。
槽位均会在编译期拒绝。 Closure 的 `use (&$value)` 是一个显式退化边界:编译器会在解析函数体前发现捕获,并从
第一次赋值开始把该局部变量保存为 `php::Var`,使逃逸闭包能够持有正常的 Zend reference。
强类型参数保持原调用 ABI,并在函数入口复制到同名的 `php::Var` 本地槽位。
- 动态函数、动态方法和 Closure 调用仍须显式使用 `std::ref()` / `toRef()`。TypePHP - 动态函数、动态方法和 Closure 调用仍须显式使用 `std::ref()` / `toRef()`。TypePHP
为本次调用建立 Zend reference,返回时校验类型并写回;动态代码若把临时引用保留到 为本次调用建立 Zend reference,返回时校验类型并写回;动态代码若把临时引用保留到
调用之外会得到明确错误。需要完整 PHP 引用身份时,应以 `std::any()` 初始化局部变量, 调用之外会得到明确错误。需要完整 PHP 引用身份时,应以 `std::any()` 初始化局部变量,
继续使用既有 `php::Var` / `php::Ref` 动态路径。 继续使用既有 `php::Var` / `php::Ref` 动态路径。
- 固定 object、resource/stream、高精度值、Native/typed object、Box 与 `std` 容器局部 - 普通 object、resource/stream 和高精度局部变量在被 Closure 按引用捕获时同样退化为
变量禁止取引用。这些值本身已有句柄或引用式语义,对其静态局部槽位改绑只会削弱类型 `php::Var`。Native object 和 `std` 容器不能安全丢弃其编译期存储布局,仍禁止这种捕获;
系统。Typed Property 仍可取引用,Zend 会附加属性 type source;PHP 数组元素仍是 Typed Property 仍可取引用,Zend 会附加属性 type source;PHP 数组元素仍是可取引用的
可取引用的动态槽位。 动态槽位。
- 带 unpack 且尾部追加 named arguments 的调用会退化为动态调用,不能使用 native call。 - 带 unpack 且尾部追加 named arguments 的调用会退化为动态调用,不能使用 native call。
## 对象模型 ## 对象模型

@ -6,8 +6,10 @@
## 目标 ## 目标
TypePHP 默认把 `int`、`string`、`float`、`bool`、`array` 保存为固定 C++ TypePHP 默认把 `int`、`string`、`float`、`bool`、`array` 保存为固定 C++
类型。引用不得迫使这些变量退化为 `php::Var`,也不得允许动态代码静默改变 类型。可静态证明不逃逸的原生引用不得迫使这些变量退化为 `php::Var`,也不得
它们的类型。 允许动态代码静默改变它们的类型。Closure 的 `use (&$var)` 是例外:函数体生成
前的捕获分析会把对应局部变量加入退化表,使其从首次赋值起使用 `php::Var`;若捕获
目标是强类型参数,则保留函数 ABI,并在入口建立同名 `php::Var` 本地槽位。
```php ```php
$value = 100; $value = 100;
@ -313,7 +315,8 @@ $intRef = '100'; // 编译期错误
- `$alias =& $object->typedProperty`、`$alias =& $array[$key]` 继续产生动态 `REF`, - `$alias =& $object->typedProperty`、`$alias =& $array[$key]` 继续产生动态 `REF`,
不产生长期持有 `RefWrap` 的 C++ 引用; 不产生长期持有 `RefWrap` 的 C++ 引用;
- 禁止返回指向函数局部存储的 typed-ref; - 禁止返回指向函数局部存储的 typed-ref;
- 禁止 Closure 按引用捕获 typed local,因为 Closure 可能超过栈帧生命周期; - Closure 按引用捕获局部变量时,不建立指向 native local 的 typed-ref;该变量在函数
解析前自动退化为 `php::Var`,逃逸 Closure 使用 Zend reference;
- 参数 typed-ref 可以继续传给静态可解析的 typed-ref 参数。 - 参数 typed-ref 可以继续传给静态可解析的 typed-ref 参数。
Generator、可挂起 Fiber 回调以及任何可能超过调用者栈帧的执行体,不得持有指向调用者 Generator、可挂起 Fiber 回调以及任何可能超过调用者栈帧的执行体,不得持有指向调用者

@ -4,7 +4,6 @@ function main(): void
{ {
echo "Hello World!"; echo "Hello World!";
var_dump(PHP_VERSION); var_dump(PHP_VERSION);
var_dump(php_uname());
global $argv; global $argv;
var_dump($argv); var_dump($argv);

@ -1,7 +1,7 @@
<?php <?php
function main(): void function main(): void
{ {
$arr = std::any([1, 2]); $arr = [1, 2];
$copy = function () use ($arr) { $copy = function () use ($arr) {
$arr[] = 3; $arr[] = 3;
return $arr; return $arr;
@ -14,7 +14,7 @@ function main(): void
}; };
$ref(); $ref();
$value = std::any('old'); $value = 'old';
$returnCapturedRef = function () use (&$value) { $returnCapturedRef = function () use (&$value) {
return $value; return $value;
}; };

@ -0,0 +1,13 @@
<?php
#[Native]
class FixedReferenceNativeObject
{
}
function fixedReferenceNativeObject(): void
{
$value = new FixedReferenceNativeObject();
$closure = static function () use (&$value): void {};
$closure();
}

@ -0,0 +1,7 @@
<?php
function nanoFileStreamMethod(string $path): string
{
$stream = fopen($path, 'rb')->toStream();
return $stream->getContents();
}

@ -0,0 +1,6 @@
<?php
function main(): void
{
pcntl_setns(1, 0);
}

@ -0,0 +1,6 @@
<?php
function parseNanoQuery(string $query): array
{
return $query->parseStr();
}

@ -0,0 +1,18 @@
<?php
function referenceCaptureAutoDegrade(): Closure
{
$value = 'fixed';
return static function () use (&$value): string {
$value = 'changed';
return $value;
};
}
function referenceCaptureParameterDegrade(string $value): Closure
{
return static function () use (&$value): array {
$value = ['changed'];
return $value;
};
}

@ -0,0 +1,11 @@
<?php
function main(): bool
{
$integer = std::bigInt(0);
$float = std::bigFloat('0');
$integerFloat = std::bigFloat(1);
$decimal = std::decimal('0');
return $integer->toBool() || $float->toBool() || $integerFloat->toBool() || $decimal->toBool();
}

@ -0,0 +1,6 @@
<?php
function main(): void
{
stream_socket_client('tcp://127.0.0.1:80');
}

@ -0,0 +1,44 @@
<?php
namespace TypePhp\Tests\Analysis;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Stmt\Function_;
use PhpParser\NodeFinder;
use PhpParser\ParserFactory;
use PHPUnit\Framework\TestCase;
use TypePhp\Analysis\ReferenceCaptureAnalyzer;
final class ReferenceCaptureAnalyzerTest extends TestCase
{
public function testCollectsOnlyCapturesOwnedByTheCurrentFunctionScope(): void
{
$nodes = (new ParserFactory())->createForHostVersion()->parse(<<<'PHP'
<?php
function example(): void {
static $staticValue = '';
global $globalValue;
$closure = function () use (&$outer, $copy): void {
$nested = function () use (&$inner): void {};
};
}
PHP);
self::assertNotNull($nodes);
$finder = new NodeFinder();
$function = $finder->findFirstInstanceOf($nodes, Function_::class);
self::assertInstanceOf(Function_::class, $function);
$analysis = (new ReferenceCaptureAnalyzer())->analyze($function->stmts);
self::assertSame(['outer' => true], $analysis['captures']);
self::assertSame(
['staticValue' => true, 'globalValue' => true],
$analysis['nonLocals'],
);
$closure = $finder->findFirstInstanceOf($function->stmts, Closure::class);
self::assertInstanceOf(Closure::class, $closure);
$nestedAnalysis = (new ReferenceCaptureAnalyzer())->analyze($closure->stmts);
self::assertSame(['inner' => true], $nestedAnalysis['captures']);
}
}

@ -0,0 +1,109 @@
<?php
namespace TypePhpTest\Build;
use Composer\InstalledVersions;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use TypePhp\Build\ComposerNativePackage;
final class ComposerNativePackageTest extends TestCase
{
/** @var array<string, mixed> */
private array $installedVersions;
private string $directory;
protected function setUp(): void
{
$this->installedVersions = InstalledVersions::getRawData();
$this->directory = sys_get_temp_dir() . '/typephp-native-package-' . bin2hex(random_bytes(6));
mkdir($this->directory . '/src', 0777, true);
file_put_contents($this->directory . '/src/example.c', 'int typephp_example(void) { return 1; }');
}
protected function tearDown(): void
{
InstalledVersions::reload($this->installedVersions);
@unlink($this->directory . '/src/example.c');
@unlink($this->directory . '/composer.json');
@rmdir($this->directory . '/src');
@rmdir($this->directory);
}
public function testLoadsStaticExtensionMetadata(): void
{
$this->installFixture(true);
$package = ComposerNativePackage::load('swoole/php-ext-example');
self::assertSame('extension', $package->kind);
self::assertSame('example', $package->extensionName);
self::assertSame('example_module_entry', $package->extensionModuleEntry);
self::assertSame([realpath($this->directory . '/src/example.c')], $package->sources);
}
public function testStaticExtensionMustRequireNanoRuntime(): void
{
$this->installFixture(false);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('must require `swoole/php-nano`');
ComposerNativePackage::load('swoole/php-ext-example');
}
public function testObsoleteExternalStandardPackageIsRejected(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('standard extension is built into');
ComposerNativePackage::load('swoole/php-ext-standard');
}
private function installFixture(bool $requireRuntime): void
{
$manifest = [
'name' => 'swoole/php-ext-example',
'require' => $requireRuntime ? ['swoole/php-nano' => '^8.6@dev'] : [],
'extra' => [
'typephp-native' => [
'kind' => 'extension',
'abi' => 80600,
'c-standard' => 11,
'cxx-standard' => 17,
'include-dirs' => ['src'],
'sources' => ['src/example.c'],
'extension' => [
'name' => 'example',
'module-entry' => 'example_module_entry',
],
],
],
];
file_put_contents(
$this->directory . '/composer.json',
json_encode($manifest, JSON_THROW_ON_ERROR),
);
InstalledVersions::reload([
'root' => [
'name' => 'typephp/test',
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => null,
'type' => 'project',
'install_path' => $this->directory,
'aliases' => [],
'dev' => true,
],
'versions' => [
'swoole/php-ext-example' => [
'pretty_version' => '0.1.0',
'version' => '0.1.0.0',
'reference' => null,
'type' => 'library',
'install_path' => $this->directory,
'aliases' => [],
'dev_requirement' => false,
],
],
]);
}
}

@ -0,0 +1,31 @@
<?php
namespace TypePhpTest\Build;
use PHPUnit\Framework\TestCase;
use TypePhp\Build\NanoBuildBackend;
final class NanoBuildBackendTest extends TestCase
{
public function testWindowsKeepsTheHostDllBuildBackend(): void
{
self::assertSame(NanoBuildBackend::WINDOWS_DLL, NanoBuildBackend::forHost('Windows'));
self::assertFalse(NanoBuildBackend::composesRuntimeSources('Windows'));
}
/** @dataProvider nonWindowsHosts */
public function testNonWindowsHostsComposeComposerRuntimeSources(string $osFamily): void
{
self::assertSame(NanoBuildBackend::COMPOSER_SOURCES, NanoBuildBackend::forHost($osFamily));
self::assertTrue(NanoBuildBackend::composesRuntimeSources($osFamily));
}
public static function nonWindowsHosts(): array
{
return [
['Linux'],
['Darwin'],
['BSD'],
];
}
}

@ -0,0 +1,132 @@
<?php
namespace TypePhpTest\Build;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use TypePhp\Build\NativeDependencyAuditor;
final class NativeDependencyAuditorTest extends TestCase
{
public function testToolchainRuntimeLibrariesAreAllowedForWasip2(): void
{
(new NativeDependencyAuditor())->assertLinkFlags(
'wasip2',
['-fwasm-exceptions', '-lsetjmp', '-lunwind', '-Wl,--gc-sections'],
);
self::addToAssertionCount(1);
}
public function testExternalLibraryIsRejected(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('must not link external library `-lcurl`');
(new NativeDependencyAuditor())->assertLinkFlags('native', ['-lcurl']);
}
public function testNativeForbiddenSymbolIsRejected(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('mmap');
(new NativeDependencyAuditor())->assertUndefinedSymbols(
'native',
" U malloc@GLIBC_2.2.5\n U mmap@GLIBC_2.2.5\n",
);
}
/** @dataProvider forbiddenNativeCapabilityProvider */
public function testNativeCapabilityFamiliesAreRejected(string $symbol): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage($symbol);
(new NativeDependencyAuditor())->assertUndefinedSymbols(
'native',
" U {$symbol}@GLIBC_2.2.5\n",
);
}
public static function forbiddenNativeCapabilityProvider(): array
{
return [
'process pipe' => ['pipe'],
'descriptor ioctl' => ['ioctl'],
'descriptor selection' => ['select'],
'network socket' => ['socket'],
'network connection' => ['connect'],
'system logging' => ['syslog'],
];
}
public function testWasip2FilesystemImportsAreAllowed(): void
{
(new NativeDependencyAuditor())->assertUndefinedSymbols(
'wasip2',
" U __imported_wasi_snapshot_preview1_fd_write\n"
. " U __imported_wasi_snapshot_preview1_fd_read\n"
. " U __imported_wasi_snapshot_preview1_path_open\n",
);
self::addToAssertionCount(1);
}
public function testNativeFilesystemSymbolsAreAllowed(): void
{
(new NativeDependencyAuditor())->assertUndefinedSymbols(
'native',
" U fopen@GLIBC_2.2.5\n"
. " U stat@GLIBC_2.2.5\n"
. " U read@GLIBC_2.2.5\n",
);
self::addToAssertionCount(1);
}
public function testOnlyNonPosixExceptionsNeedAnAllowlistEntry(): void
{
self::assertContains('flock', NativeDependencyAuditor::NON_POSIX_HOST_FUNCTION_ALLOWLIST);
foreach (['fopen', 'strdup', 'dup', 'dup2', 'mkstemp', 'chown', 'getuid'] as $standardName) {
self::assertNotContains(
$standardName,
NativeDependencyAuditor::NON_POSIX_HOST_FUNCTION_ALLOWLIST,
);
}
self::assertNotContains('socket', NativeDependencyAuditor::NON_POSIX_HOST_FUNCTION_ALLOWLIST);
}
public function testConsoleOutputImportsAreAllowed(): void
{
(new NativeDependencyAuditor())->assertUndefinedSymbols(
'wasip2',
" U __imported_wasi_snapshot_preview1_fd_write\n"
. " U __imported_wasi_snapshot_preview1_proc_exit\n",
);
self::addToAssertionCount(1);
}
public function testWasip2RejectsSocketAndProcessControlImports(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('proc_raise');
(new NativeDependencyAuditor())->assertUndefinedSymbols(
'wasip2',
" U __imported_wasi_snapshot_preview1_fd_write\n"
. " U __imported_wasi_snapshot_preview1_sock_recv\n"
. " U __imported_wasi_snapshot_preview1_proc_raise\n",
);
}
public function testStandardLibraryClockSleepAndEntropyImportsAreAllowed(): void
{
$auditor = new NativeDependencyAuditor();
$auditor->assertUndefinedSymbols(
'native',
" U clock_gettime@GLIBC_2.17\n"
. " U nanosleep@GLIBC_2.2.5\n",
);
$auditor->assertUndefinedSymbols(
'wasip2',
" U __imported_wasi_snapshot_preview1_clock_time_get\n"
. " U __imported_wasi_snapshot_preview1_poll_oneoff\n"
. " U __imported_wasi_snapshot_preview1_random_get\n",
);
self::addToAssertionCount(1);
}
}

@ -0,0 +1,72 @@
<?php
namespace TypePhpTest\Build;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use TypePhp\Build\NativeSourceProjectConfig;
final class NativeSourceProjectConfigTest extends TestCase
{
private string $directory;
protected function setUp(): void
{
$this->directory = sys_get_temp_dir() . '/typephp-native-project-' . bin2hex(random_bytes(6));
mkdir($this->directory . '/generated', 0777, true);
file_put_contents($this->directory . '/generated/runtime.c', 'int native_c(void) { return 1; }');
file_put_contents($this->directory . '/generated/main.cpp', 'int main() { return 0; }');
file_put_contents($this->directory . '/generated/main.php', '<?php function main(): void {}');
}
protected function tearDown(): void
{
@unlink($this->directory . '/generated/runtime.c');
@unlink($this->directory . '/generated/main.cpp');
@unlink($this->directory . '/generated/main.php');
@unlink($this->directory . '/generated/invalid.txt');
@unlink($this->directory . '/project.xml');
@rmdir($this->directory . '/generated');
@rmdir($this->directory);
}
public function testPhpCAndCppSourcesAreAccepted(): void
{
file_put_contents($this->directory . '/project.xml', <<<'XML'
<?xml version="1.0"?>
<project mode="native" name="mixed-source">
<sources>
<source>generated/main.php</source>
<source>generated/runtime.c</source>
<source>generated/main.cpp</source>
</sources>
</project>
XML);
$config = NativeSourceProjectConfig::load($this->directory . '/project.xml');
self::assertSame('cc', $config->cCompiler);
self::assertSame('c++', $config->compiler);
self::assertSame([
realpath($this->directory . '/generated/main.php'),
], $config->phpSources);
self::assertSame([
realpath($this->directory . '/generated/runtime.c'),
realpath($this->directory . '/generated/main.cpp'),
], $config->sources);
}
public function testNonNativeSourceIsRejected(): void
{
file_put_contents($this->directory . '/generated/invalid.txt', 'invalid');
file_put_contents($this->directory . '/project.xml', <<<'XML'
<project mode="native">
<sources><source>generated/invalid.txt</source></sources>
</project>
XML);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('must be PHP, C, or C++');
NativeSourceProjectConfig::load($this->directory . '/project.xml');
}
}

@ -0,0 +1,54 @@
<?php
namespace TypePhp\Tests;
use PHPUnit\Framework\TestCase;
final class CliUsageTest extends TestCase
{
public function testHelpSeparatesSubcommandsFromCompilationOptions(): void
{
$process = proc_open(
[PHP_BINARY, TYPEPHP_ROOT_PATH . '/bin/tpc.php', '--help'],
[
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
],
$pipes,
TYPEPHP_ROOT_PATH,
);
self::assertIsResource($process);
$output = stream_get_contents($pipes[1]);
$error = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$status = proc_close($process);
self::assertSame(0, $status, $error);
self::assertIsString($output);
$subcommands = self::section($output, 'SUBCOMMANDS:', 'EXAMPLES:');
self::assertStringContainsString('--gen-python-helper', $subcommands);
self::assertStringContainsString('--convert-python-to-php', $subcommands);
self::assertStringContainsString('--generate-completion=bash', $subcommands);
$compilationOptions = self::section($output, 'COMPILATION OPTIONS:', 'GENERAL OPTIONS:');
self::assertStringContainsString('--nano', $compilationOptions);
self::assertStringContainsString('--wasm[=browser|component]', $compilationOptions);
self::assertStringNotContainsString('--gen-python-helper', $compilationOptions);
self::assertStringNotContainsString('--convert-python-to-php', $compilationOptions);
self::assertStringNotContainsString('--generate-completion', $compilationOptions);
}
private static function section(string $output, string $start, string $end): string
{
$startOffset = strpos($output, $start);
$endOffset = strpos($output, $end);
self::assertNotFalse($startOffset);
self::assertNotFalse($endOffset);
self::assertGreaterThan($startOffset, $endOffset);
return substr($output, $startOffset, $endOffset - $startOffset);
}
}

@ -17,9 +17,15 @@ class ClosureTest extends \BaseTest
$code = file_get_contents($generated); $code = file_get_contents($generated);
self::assertIsString($code); 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::assertStringContainsString('auto copy = [arr = arr]() mutable -> php::Var {', $code);
self::assertSame(2, substr_count($code, 'php::newClosureWithParameters(')); self::assertStringContainsString('auto ref = [&arr]() mutable -> php::Var {', $code);
self::assertSame(2, substr_count($code, 'php::ClosureStrictTypes::Enabled')); self::assertStringContainsString(
'auto returnCapturedRef = [&value]() mutable -> php::Var {',
$code,
);
self::assertStringNotContainsString('php::newClosureWithParameters(', $code);
} }
public function testClosureRebindingIsRejectedAtCompileTime(): void public function testClosureRebindingIsRejectedAtCompileTime(): void

@ -459,6 +459,18 @@ PHP);
$this->assertStringContainsString('App\\\\Worker\\\\Config\\\\PATH', $code); $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 public function testDynamicallyDefinedConstantsAreNotInternalConstants(): void
{ {
$name = 'AOT_USER_DEFINE_' . str_replace('.', '_', uniqid('', true)); $name = 'AOT_USER_DEFINE_' . str_replace('.', '_', uniqid('', true));

@ -48,6 +48,35 @@ PHP,
$extension, $extension,
); );
self::assertStringNotContainsString('php::eval("\\n', $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'
<?php
function main(int $argc, array $argv): void
{
}
PHP);
global $translator;
$compiler = CompilerTest::create($this->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 private function removeDirectory(string $directory): void

@ -5,21 +5,41 @@ use TypePhp\Exception\TestError;
final class FixedReferenceStorageTest extends BaseTest final class FixedReferenceStorageTest extends BaseTest
{ {
/** @dataProvider fixedStorageProvider */ /** @dataProvider referenceCaptureDegradationProvider */
public function testFixedStorageCannotBeCapturedByReference(string $fixture, string $type): void public function testReferenceCaptureAutomaticallyDegradesLocalStorage(string $fixture): void
{ {
$this->expectException(TestError::class); $code = $this->compileFixture($fixture);
$this->expectExceptionMessage('of fixed type ' . $type . '; initialize it with std::any()');
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); $this->compileFixture($fixture);
} }
public static function fixedStorageProvider(): iterable public static function specializedStorageProvider(): iterable
{ {
yield 'object' => ['fixed-reference-generic-object.php', 'php::Object']; yield 'std container' => [
yield 'typed object' => ['fixed-reference-object.php', 'php::Object']; 'fixed-reference-std-container.php',
yield 'stream' => ['fixed-reference-stream.php', 'php::Stream']; 'Std container variable `$value` cannot be degraded to var for Closure reference capture',
yield 'std container' => ['fixed-reference-std-container.php', 'php::StdVector']; ];
yield 'Native object' => [
'fixed-reference-native-object.php',
'Native object variable `$value` cannot be degraded to var for Closure reference capture',
];
} }
/** @dataProvider localClosureFixedReferenceProvider */ /** @dataProvider localClosureFixedReferenceProvider */
@ -30,6 +50,7 @@ final class FixedReferenceStorageTest extends BaseTest
{ {
$code = $this->compileFixture($fixture); $code = $this->compileFixture($fixture);
self::assertStringContainsString('php::Var value;', $code);
self::assertStringContainsString($capture, $code); self::assertStringContainsString($capture, $code);
self::assertStringNotContainsString('php::newClosureWithParameters(', $code); self::assertStringNotContainsString('php::newClosureWithParameters(', $code);
} }
@ -92,12 +113,11 @@ final class FixedReferenceStorageTest extends BaseTest
$this->compileFixture('typed-reference-parameter-return.php'); $this->compileFixture('typed-reference-parameter-return.php');
} }
public function testFixedStaticStorageCannotBeCapturedByReference(): void public function testFixedStaticStorageDegradesBeforeReferenceCapture(): void
{ {
$this->expectException(TestError::class); $code = $this->compileFixture('fixed-reference-static.php');
$this->expectExceptionMessage('variable $value of fixed type php::Str');
$this->compileFixture('fixed-reference-static.php'); self::assertStringContainsString('value.toReference()', $code);
} }
public function testFixedStorageUsesBridgeForToRefAtKnownDynamicReferenceBoundary(): void public function testFixedStorageUsesBridgeForToRefAtKnownDynamicReferenceBoundary(): void
@ -114,7 +134,24 @@ final class FixedReferenceStorageTest extends BaseTest
$code = $this->compileFixture('fixed-reference-explicit-any.php'); $code = $this->compileFixture('fixed-reference-explicit-any.php');
self::assertStringContainsString('php::Var value', $code); 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 */ /** @dataProvider invalidTypedReferenceOperationProvider */

@ -34,8 +34,13 @@ final class LocalClosureCodegenTest extends BaseTest
self::assertStringContainsString('direct(2L)', $code); self::assertStringContainsString('direct(2L)', $code);
self::assertStringNotContainsString('typephp_call_cached(direct', $code); self::assertStringNotContainsString('typephp_call_cached(direct', $code);
// Escaped values and dynamic references remain real Zend Closures. // Only the escaping closure needs a real Zend Closure. The local reference
self::assertSame(2, substr_count($code, 'php::newClosureWithParameters(')); // capture uses the storage selected by the function degradation table.
self::assertStringContainsString('typephp_call_cached(dynamicRef', $code); self::assertSame(1, substr_count($code, 'php::newClosureWithParameters('));
self::assertStringContainsString(
'auto dynamicRef = [&dynamic]() mutable -> php::Var {',
$code,
);
self::assertStringNotContainsString('typephp_call_cached(dynamicRef', $code);
} }
} }

@ -0,0 +1,271 @@
<?php
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use TypePhp\CompilerTest;
use TypePhp\Exception\TestError;
final class NanoCapabilityPolicyCompiler extends CompilerTest
{
public function enableNanoForTest(): void
{
$this->nanoMode = true;
$this->forTest = true;
$this->file = 'nano-policy.php';
}
public function enableWasiForTest(): void
{
$this->nanoMode = true;
$this->nanoPolicyMode = true;
$this->targetPlatform = 'wasm32-wasip2';
$this->forTest = true;
$this->file = 'nano-policy.php';
}
public function enableFullRuntimeNanoPolicyForTest(): void
{
$this->nanoMode = false;
$this->nanoPolicyMode = true;
$this->forTest = true;
$this->file = 'nano-policy.php';
}
public function validateNanoFunction(string $name): void
{
$this->assertNanoFunctionSupported(new FuncCall(new Name($name)), $name);
}
public function validateWasiFunction(string $name): void
{
$this->assertWasiFunctionSupported(new FuncCall(new Name($name)), $name);
}
public function forgetBuildTimeFunction(string $name): void
{
unset($this->internalFunctions[$name]);
}
}
final class NanoCapabilityPolicyTest extends BaseTest
{
public function testRejectsForbiddenDirectCallMissingFromBuildTimePhp(): void
{
global $translator;
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH);
$compiler->enableNanoForTest();
$compiler->forgetBuildTimeFunction('pcntl_setns');
$translator = $compiler;
$source = __DIR__ . '/../code/nano-unavailable-host-function.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$this->expectException(TestError::class);
$this->expectExceptionMessage('Function `pcntl_setns` is not supported in nano mode');
$compiler->convertFile($source);
}
public function testRejectsHostCapabilityFunctions(): void
{
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH);
$compiler->enableNanoForTest();
foreach (['shell_exec', 'stream_socket_client', 'stream_select', 'parse_str'] as $name) {
try {
$compiler->validateNanoFunction($name);
self::fail("{$name} was accepted");
} catch (TestError $error) {
self::assertStringContainsString("Function `{$name}` is not supported in nano mode", $error->getMessage());
}
}
}
public function testFullRuntimeNanoPolicyRejectsExternalCommandsOnly(): void
{
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH);
$compiler->enableFullRuntimeNanoPolicyForTest();
foreach (['exec', 'passthru', 'pcntl_exec', 'popen', 'proc_open', 'proc_terminate', 'shell_exec', 'system'] as $name) {
try {
$compiler->validateNanoFunction($name);
self::fail("{$name} was accepted");
} catch (TestError $error) {
self::assertStringContainsString("Function `{$name}` is not supported in nano mode", $error->getMessage());
}
}
// Windows uses the complete PHP/PHPX DLL runtime. The php-nano-only
// capability reductions are therefore not applied to that target.
foreach (['getenv', 'parse_str', 'stream_socket_client'] as $name) {
$compiler->validateNanoFunction($name);
}
self::addToAssertionCount(3);
}
public function testFullRuntimeNanoEntryCallsGeneratedMainWithoutEval(): void
{
global $translator;
$previousTranslator = $translator ?? null;
$directory = sys_get_temp_dir() . '/typephp_nano_policy_' . bin2hex(random_bytes(6));
mkdir($directory, 0777, true);
$source = $directory . '/main.php';
file_put_contents($source, "<?php\nfunction main(): void {}\n");
try {
$compiler = new NanoCapabilityPolicyCompiler($directory);
$compiler->enableFullRuntimeNanoPolicyForTest();
$compiler->setBuildMode(\TypePhp\CompilerBase::BUILD_MODE_BIN);
$compiler->setTargetName('nano_policy_entry');
$translator = $compiler;
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$compiler->convertFile($source);
$extension = file_get_contents($compiler->genExtension());
self::assertIsString($extension);
self::assertStringContainsString('php_main();', $extension);
self::assertStringNotContainsString('php::eval(', $extension);
self::assertStringContainsString(
'zend_disable_functions("exec,passthru,pcntl_exec,popen,proc_close,proc_get_status,proc_nice,proc_open,proc_terminate,shell_exec,system")',
$extension,
);
self::assertStringContainsString('_SERVER.item("SCRIPT_FILENAME", true)', $extension);
} finally {
$translator = $previousTranslator;
self::removeDirectory($directory);
}
}
private static function removeDirectory(string $directory): void
{
if (!is_dir($directory)) {
return;
}
foreach (array_diff(scandir($directory), ['.', '..']) as $entry) {
$path = $directory . DIRECTORY_SEPARATOR . $entry;
if (is_dir($path)) {
self::removeDirectory($path);
} else {
unlink($path);
}
}
rmdir($directory);
}
public function testKeepsFileStreamsAndFileHashesAvailable(): void
{
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH);
$compiler->enableNanoForTest();
foreach (['fopen', 'file_get_contents', 'file_put_contents', 'is_file', 'realpath', 'hash_file', 'stream_get_contents', 'flock', 'umask', 'chown'] as $name) {
$compiler->validateNanoFunction($name);
}
self::addToAssertionCount(10);
}
public function testWasiOnlyRejectsItsMissingFileCapabilities(): void
{
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH);
$compiler->enableWasiForTest();
foreach (['flock', 'umask', 'chown', 'stream_socket_client', 'gethostbyname', 'dns_get_record'] as $name) {
try {
$compiler->validateWasiFunction($name);
self::fail("{$name} was accepted");
} catch (TestError $error) {
self::assertStringContainsString("Function `{$name}` is not supported by the WASI target", $error->getMessage());
}
}
foreach (['fopen', 'file_get_contents', 'hash_file'] as $name) {
$compiler->validateWasiFunction($name);
}
self::addToAssertionCount(3);
}
public function testWasiUnsupportedDirectCallFailsBeforeCodeGeneration(): void
{
global $translator;
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH);
$compiler->enableWasiForTest();
$translator = $compiler;
$source = __DIR__ . '/../code/wasi-unavailable-host-function.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$this->expectException(TestError::class);
$this->expectExceptionMessage(
'Function `stream_socket_client` is not supported by the WASI target',
);
$compiler->convertFile($source);
}
public function testNanoKeepsTypedFileStreamMethods(): void
{
global $translator;
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH);
$compiler->enableNanoForTest();
$translator = $compiler;
$source = __DIR__ . '/../code/nano-file-stream-method.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$code = file_get_contents($compiler->convertFile($source));
self::assertIsString($code);
self::assertStringContainsString('php::toStream(', $code);
self::assertStringContainsString('php::call(', $code);
}
public function testRejectsUnavailableStringMethodAtCompileTime(): void
{
global $translator;
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH);
$compiler->enableNanoForTest();
$translator = $compiler;
$source = __DIR__ . '/../code/nano-unavailable-string-method.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$this->expectException(TestError::class);
$this->expectExceptionMessage('Function `parse_str` is not supported in nano mode');
$compiler->convertFile($source);
}
public function testKeepsZendIniFunctionsAvailable(): void
{
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH);
$compiler->enableNanoForTest();
$compiler->validateNanoFunction('ini_get');
$compiler->validateNanoFunction('ini_set');
self::addToAssertionCount(2);
}
public function testKeepsCompilerCtypeIntrinsicAvailable(): void
{
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH);
$compiler->enableNanoForTest();
$compiler->validateNanoFunction('ctype_digit');
$this->addToAssertionCount(1);
}
public function testKeepsStandardCppTimeFunctionsAvailable(): void
{
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH);
$compiler->enableNanoForTest();
foreach (['sleep', 'usleep', 'time_nanosleep', 'time_sleep_until', 'hrtime', 'microtime'] as $name) {
$compiler->validateNanoFunction($name);
}
$this->addToAssertionCount(6);
}
public function testKeepsStandardCppRandomFunctionsAvailable(): void
{
$compiler = new NanoCapabilityPolicyCompiler(TYPEPHP_ROOT_PATH);
$compiler->enableNanoForTest();
$compiler->validateNanoFunction('random_bytes');
$compiler->validateNanoFunction('random_int');
$this->addToAssertionCount(2);
}
}

@ -0,0 +1,142 @@
<?php
namespace TypePhpTest\Transform;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\ParserFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use TypePhp\Transform\NanoSyntaxValidationVisitor;
final class NanoSyntaxValidationVisitorTest extends TestCase
{
/** @return iterable<string, array{string, string}> */
public static function unsupportedSyntax(): iterable
{
yield 'eval' => ['<?php function run(): void { eval("return 1;"); }', '`eval`'];
yield 'include' => ['<?php function run(): void { include "a.php"; }', '`include`'];
yield 'include once' => ['<?php function run(): void { include_once "a.php"; }', '`include_once`'];
yield 'require' => ['<?php function run(): void { require "a.php"; }', '`require`'];
yield 'require once' => ['<?php function run(): void { require_once "a.php"; }', '`require_once`'];
yield 'backticks' => ['<?php function run(): string { return `uname`; }', 'Backtick shell execution'];
yield 'anonymous class' => ['<?php function run(): object { return new class {}; }', 'Anonymous classes'];
yield 'yield' => ['<?php function values(): iterable { yield 1; }', 'Fiber and Generator'];
yield 'yield from' => ['<?php function values(): iterable { yield from [1]; }', 'Fiber and Generator'];
yield 'generator closure' => [
'<?php function values(): Closure { return function (): iterable { yield 1; }; }',
'Fiber and Generator',
];
yield 'generator arrow function' => [
'<?php function values(): Closure { return fn (): iterable => yield 1; }',
'Fiber and Generator',
];
yield 'Fiber class' => [
'<?php function run(): object { return new Fiber(static function (): void {}); }',
'Class `Fiber` is not supported',
];
yield 'Generator type' => [
'<?php function values(): Generator { throw new RuntimeException(); }',
'Class `Generator` is not supported',
];
yield 'ReflectionGenerator class' => [
'<?php function inspect(object $value): object { return new ReflectionGenerator($value); }',
'Class `ReflectionGenerator` is not supported',
];
}
#[DataProvider('unsupportedSyntax')]
public function testRejectsRuntimeCompiledSyntax(string $source, string $expected): void
{
$parser = (new ParserFactory())->createForNewestSupportedVersion();
$nodes = $parser->parse($source);
self::assertNotNull($nodes);
$traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false]));
$traverser->addVisitor(new NanoSyntaxValidationVisitor(
static function (Node $node, string $message): never {
throw new RuntimeException($message . ':' . $node->getStartLine());
},
));
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage($expected);
$traverser->traverse($nodes);
}
public function testAcceptsNamedClassesAndOrdinaryObjectConstruction(): void
{
$parser = (new ParserFactory())->createForNewestSupportedVersion();
$nodes = $parser->parse(
'<?php class Value {} function run(): object { return new Value(); }',
);
self::assertNotNull($nodes);
$traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false]));
$traverser->addVisitor(new NanoSyntaxValidationVisitor(
static function (Node $node, string $message): never {
throw new RuntimeException($message . ':' . $node->getStartLine());
},
));
self::assertCount(2, $traverser->traverse($nodes));
}
public function testAcceptsNamespacedUserClassNamedFiber(): void
{
$parser = (new ParserFactory())->createForNewestSupportedVersion();
$nodes = $parser->parse(
'<?php namespace App; class Fiber {} function run(): Fiber { return new Fiber(); }',
);
self::assertNotNull($nodes);
$traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false]));
$traverser->addVisitor(new NanoSyntaxValidationVisitor(
static function (Node $node, string $message): never {
throw new RuntimeException($message . ':' . $node->getStartLine());
},
));
self::assertCount(1, $traverser->traverse($nodes));
}
public function testFullRuntimeNanoPolicyKeepsGeneratorsButRejectsVmEntrySyntax(): void
{
$parser = (new ParserFactory())->createForNewestSupportedVersion();
$accepted = $parser->parse(
'<?php function values(): Generator { yield 1; }',
);
self::assertNotNull($accepted);
$traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false]));
$traverser->addVisitor(new NanoSyntaxValidationVisitor(
static function (Node $node, string $message): never {
throw new RuntimeException($message . ':' . $node->getStartLine());
},
false,
));
self::assertCount(1, $traverser->traverse($accepted));
foreach ([
'<?php eval("return 1;");',
'<?php require "a.php";',
'<?php `uname`;',
'<?php $value = new class {};',
] as $source) {
$nodes = $parser->parse($source);
self::assertNotNull($nodes);
try {
$traverser->traverse($nodes);
self::fail("Nano policy accepted unsupported source: {$source}");
} catch (RuntimeException) {
self::addToAssertionCount(1);
}
}
}
}

@ -22,4 +22,22 @@ class UniversalMethodCallTest extends \BaseTest
$this->compile('void-method-call.php'); $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);
}
} }

@ -85,14 +85,19 @@ final class VarIntTypesTest extends BaseTest
self::assertStringNotContainsString('php::newClosureWithParameters(', $code); self::assertStringNotContainsString('php::newClosureWithParameters(', $code);
} }
public function testExplicitAnySupportsReferenceCapture(): void public function testExplicitAnyUsesDirectReferenceCaptureInNonEscapingClosure(): void
{ {
$code = $this->compileSource( $code = $this->compileSource(
$this->createCompiler(), $this->createCompiler(),
TYPEPHP_ROOT_PATH . '/phpunit/code/any-reference-capture.php', TYPEPHP_ROOT_PATH . '/phpunit/code/any-reference-capture.php',
); );
self::assertStringContainsString('php::Var changed', $code); 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 public function testDestructuringKeepsNativeTargetsAndConvertsDynamicItems(): void

@ -0,0 +1,85 @@
<?php
/**
* This file is part of TypePHP.
*
* @link https://www.swoole.com/
* @contact service@swoole.com
*/
namespace TypePhp\Analysis;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\FunctionLike;
use PhpParser\Node\Stmt;
/**
* Finds locals which need dynamic storage because a child Closure captures
* them by reference. Nested function bodies are separate variable scopes and
* are deliberately analyzed when their own FunctionContext is created.
*/
final class ReferenceCaptureAnalyzer
{
/**
* @param Node|list<Node>|null $body
* @return array{captures: array<string, true>, nonLocals: array<string, true>}
*/
public function analyze(Node|array|null $body): array
{
$captures = [];
$nonLocals = [];
$this->scan($body, $captures, $nonLocals);
return ['captures' => $captures, 'nonLocals' => $nonLocals];
}
/**
* @param array<string, true> $captures
* @param array<string, true> $nonLocals
*/
private function scan(mixed $value, array &$captures, array &$nonLocals): void
{
foreach (is_array($value) ? $value : [$value] as $node) {
if (!$node instanceof Node) {
continue;
}
if ($node instanceof Expr\Closure) {
foreach ($node->uses as $use) {
if ($use->byRef
&& $use->var instanceof Expr\Variable
&& is_string($use->var->name)
) {
$captures[$use->var->name] = true;
}
}
// The Closure body owns another FunctionContext.
continue;
}
if ($node instanceof Expr\ArrowFunction
|| $node instanceof Stmt\Function_
|| $node instanceof Stmt\ClassLike
|| $node instanceof FunctionLike
) {
continue;
}
if ($node instanceof Stmt\Static_) {
foreach ($node->vars as $var) {
if ($var->var instanceof Expr\Variable && is_string($var->var->name)) {
$nonLocals[$var->var->name] = true;
}
}
} elseif ($node instanceof Stmt\Global_) {
foreach ($node->vars as $var) {
if ($var instanceof Expr\Variable && is_string($var->name)) {
$nonLocals[$var->name] = true;
}
}
}
foreach ($node->getSubNodeNames() as $field) {
$this->scan($node->{$field}, $captures, $nonLocals);
}
}
}
}

@ -96,6 +96,16 @@ abstract class GccLikeBackend extends CompilerBackend
$cmd .= ' -march=' . $config['march']; $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'])) { if (!empty($config['target_platform'])) {
$cmd .= ' --target=' . $config['target_platform']; $cmd .= ' --target=' . $config['target_platform'];
} }
@ -209,6 +219,10 @@ abstract class GccLikeBackend extends CompilerBackend
$cmd .= ' ' . escapeshellarg($sourceFile); $cmd .= ' ' . escapeshellarg($sourceFile);
$cmd .= ' -o ' . escapeshellarg($outputFile); $cmd .= ' -o ' . escapeshellarg($outputFile);
if (!empty($options['c_std'])) {
$cmd .= ' -std=' . $options['c_std'];
}
if (!empty($options['include_paths'])) { if (!empty($options['include_paths'])) {
$cmd .= ' ' . $this->formatIncludePaths($options['include_paths']); $cmd .= ' ' . $this->formatIncludePaths($options['include_paths']);
} }

@ -0,0 +1,228 @@
<?php
namespace TypePhp\Build;
use Composer\InstalledVersions;
use RuntimeException;
/** A Composer package that contributes C or C++ sources to a TypePHP native target. */
final readonly class ComposerNativePackage
{
/**
* @param list<string> $includeDirs
* @param list<string> $sources
*/
private function __construct(
public string $name,
public string $installPath,
public string $kind,
public int $abi,
public int $cStandard,
public int $cxxStandard,
public array $includeDirs,
public array $sources,
public ?string $extensionName,
public ?string $extensionModuleEntry,
) {
}
/** @return list<self> */
public static function discover(): array
{
$packages = [];
foreach (InstalledVersions::getInstalledPackages() as $package) {
$installPath = InstalledVersions::getInstallPath($package);
if (!is_string($installPath)) {
continue;
}
$manifestPath = $installPath . DIRECTORY_SEPARATOR . 'composer.json';
$manifest = json_decode((string) @file_get_contents($manifestPath), true);
if (!is_array($manifest)
|| !is_array($manifest['extra']['typephp-native'] ?? null)) {
continue;
}
$packages[] = self::load($package);
}
usort(
$packages,
static function (self $left, self $right): int {
$priority = ['runtime' => 0, 'bridge' => 1, 'library' => 2, 'extension' => 3];
return [$priority[$left->kind], $left->name]
<=> [$priority[$right->kind], $right->name];
},
);
return $packages;
}
public static function load(string $package): self
{
if ($package === 'swoole/php-ext-standard') {
throw new RuntimeException(
'The standard extension is built into swoole/php-nano; remove obsolete package `swoole/php-ext-standard`'
);
}
$installPath = class_exists(InstalledVersions::class) && InstalledVersions::isInstalled($package)
? InstalledVersions::getInstallPath($package)
: null;
$root = is_string($installPath) ? realpath($installPath) : false;
if ($root === false) {
$root = self::resolveSiblingPackage($package);
}
if ($root === null) {
throw new RuntimeException(
"Native dependency `{$package}` is not installed; run `composer require {$package}`"
);
}
if (!is_dir($root)) {
throw new RuntimeException("Unable to resolve Composer install path for `{$package}`");
}
$manifestPath = $root . DIRECTORY_SEPARATOR . 'composer.json';
$manifest = json_decode((string) file_get_contents($manifestPath), true);
$native = is_array($manifest) ? ($manifest['extra']['typephp-native'] ?? null) : null;
if (!is_array($native)) {
throw new RuntimeException(
"Composer package `{$package}` does not publish extra.typephp-native"
);
}
$kind = $native['kind'] ?? 'library';
if (!is_string($kind) || !in_array($kind, ['runtime', 'bridge', 'extension', 'library'], true)) {
throw new RuntimeException("Invalid native package kind in `{$package}`");
}
$extensionName = null;
$extensionModuleEntry = null;
if ($kind === 'extension') {
if (!str_starts_with($package, 'swoole/php-ext-')) {
throw new RuntimeException(
"Native extension package `{$package}` must use the `swoole/php-ext-*` naming convention"
);
}
$extension = $native['extension'] ?? null;
$extensionName = is_array($extension) ? ($extension['name'] ?? null) : null;
$extensionModuleEntry = is_array($extension) ? ($extension['module-entry'] ?? null) : null;
if (!is_string($extensionName)
|| preg_match('/^[a-z][a-z0-9_]*$/', $extensionName) !== 1
|| !is_string($extensionModuleEntry)
|| preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $extensionModuleEntry) !== 1) {
throw new RuntimeException("Invalid native extension metadata in `{$package}`");
}
$expectedPackage = 'swoole/php-ext-' . str_replace('_', '-', $extensionName);
if ($package !== $expectedPackage) {
throw new RuntimeException(
"Native extension `{$extensionName}` must use Composer package `{$expectedPackage}`"
);
}
$runtimeConstraint = $manifest['require']['swoole/php-nano'] ?? null;
if (!is_string($runtimeConstraint) || $runtimeConstraint === '') {
throw new RuntimeException(
"Native extension package `{$package}` must require `swoole/php-nano`"
);
}
}
$abi = $native['abi'] ?? null;
$cStandard = $native['c-standard'] ?? null;
$cxxStandard = $native['cxx-standard'] ?? null;
if (!is_int($abi) || !is_int($cStandard) || !is_int($cxxStandard)) {
throw new RuntimeException("Invalid native ABI metadata in `{$package}`");
}
if ($cStandard !== 11) {
throw new RuntimeException(
"Native dependency `{$package}` requires unsupported C{$cStandard}; expected C11"
);
}
if ($cxxStandard !== 17) {
throw new RuntimeException(
"Native dependency `{$package}` requires unsupported C++{$cxxStandard}; expected C++17"
);
}
$sources = self::resolveEntries($root, $native['sources'] ?? null, false, $package);
foreach ($sources as $source) {
$extension = strtolower(pathinfo($source, PATHINFO_EXTENSION));
if (!in_array($extension, ['c', 'cc', 'cpp', 'cxx'], true)) {
throw new RuntimeException(
"Native package `{$package}` contains unsupported source type: {$source}"
);
}
}
return new self(
$package,
$root,
$kind,
$abi,
$cStandard,
$cxxStandard,
self::resolveEntries($root, $native['include-dirs'] ?? null, true, $package),
$sources,
$extensionName,
$extensionModuleEntry,
);
}
/**
* Composer installs all three core packages as siblings below vendor/swoole.
* The same layout is used by the monorepo checkout, where a package may not
* yet be present in the checkout's generated InstalledVersions metadata.
*/
private static function resolveSiblingPackage(string $package): ?string
{
if (!defined('TYPEPHP_ROOT_PATH') || !str_starts_with($package, 'swoole/')) {
return null;
}
$candidate = realpath(
dirname(TYPEPHP_ROOT_PATH) . DIRECTORY_SEPARATOR . substr($package, strlen('swoole/')),
);
if ($candidate === false || !is_dir($candidate)) {
return null;
}
$manifest = json_decode((string) @file_get_contents($candidate . '/composer.json'), true);
return is_array($manifest) && ($manifest['name'] ?? null) === $package
? $candidate
: null;
}
/** @return list<string> */
private static function resolveEntries(
string $root,
mixed $entries,
bool $directories,
string $package,
): array {
if (!is_array($entries) || $entries === []) {
throw new RuntimeException("Native source metadata is empty in `{$package}`");
}
$resolved = [];
foreach ($entries as $entry) {
if (!is_string($entry) || $entry === '' || self::isAbsolutePath($entry)) {
throw new RuntimeException("Invalid native source path in `{$package}`");
}
$path = realpath($root . DIRECTORY_SEPARATOR . $entry);
$validType = $path !== false && ($directories ? is_dir($path) : is_file($path));
if (!$validType || !self::isInside($root, (string) $path)) {
throw new RuntimeException(
"Native source entry `{$entry}` is missing or escapes package `{$package}`"
);
}
$resolved[] = $path;
}
return $resolved;
}
private static function isInside(string $root, string $path): bool
{
return $path === $root
|| str_starts_with($path, rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
}
private static function isAbsolutePath(string $path): bool
{
return $path[0] === '/' || $path[0] === '\\'
|| preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1;
}
}

@ -0,0 +1,23 @@
<?php
namespace TypePhp\Build;
/** Selects the runtime build backend used by the --nano policy mode. */
final class NanoBuildBackend
{
/** Existing Windows host build linked through the PHP/PHPX import libraries. */
public const WINDOWS_DLL = 'windows-dll';
/** Composer package manifests whose C/C++ sources are compiled into the program. */
public const COMPOSER_SOURCES = 'composer-sources';
public static function forHost(string $osFamily): string
{
return $osFamily === 'Windows' ? self::WINDOWS_DLL : self::COMPOSER_SOURCES;
}
public static function composesRuntimeSources(string $osFamily): bool
{
return self::forHost($osFamily) === self::COMPOSER_SOURCES;
}
}

@ -0,0 +1,113 @@
<?php
namespace TypePhp\Build;
use RuntimeException;
/** Resolves the Composer-provided runtime sources used by a Nano build. */
final class NanoSourceComposer
{
/**
* @return array{
* packages: list<ComposerNativePackage>,
* packageSources: list<string>,
* includeDirs: list<string>,
* registry: string
* }
*/
public function compose(string $buildDir, string $targetName, bool $registerProject): array
{
$runtime = ComposerNativePackage::load('swoole/php-nano');
$phpx = ComposerNativePackage::load('swoole/phpx');
if ($runtime->abi !== $phpx->abi) {
throw new RuntimeException(
"Native ABI mismatch: swoole/php-nano={$runtime->abi}, swoole/phpx={$phpx->abi}"
);
}
$packagesByName = [
$runtime->name => $runtime,
$phpx->name => $phpx,
];
foreach (ComposerNativePackage::discover() as $package) {
$packagesByName[$package->name] = $package;
}
$packages = array_values($packagesByName);
$packageSources = [];
$includeDirs = [];
foreach ($packages as $package) {
if ($package->abi !== $runtime->abi) {
throw new RuntimeException(
"Native ABI mismatch: {$package->name}={$package->abi}, "
. "swoole/php-nano={$runtime->abi}"
);
}
array_push($packageSources, ...$package->sources);
array_push($includeDirs, ...$package->includeDirs);
}
return [
'packages' => $packages,
'packageSources' => array_values(array_unique($packageSources)),
'includeDirs' => array_values(array_unique($includeDirs)),
'registry' => $this->writeExtensionRegistry(
$buildDir,
$targetName,
$packages,
$registerProject,
),
];
}
/** @param list<ComposerNativePackage> $packages */
private function writeExtensionRegistry(
string $buildDir,
string $targetName,
array $packages,
bool $registerProject,
): string {
if (!is_dir($buildDir) && !mkdir($buildDir, 0777, true) && !is_dir($buildDir)) {
throw new RuntimeException("Unable to create Nano build directory: {$buildDir}");
}
$extensions = array_values(array_filter(
$packages,
static fn(ComposerNativePackage $package): bool => $package->kind === 'extension',
));
$path = $buildDir . DIRECTORY_SEPARATOR . 'composer_extensions.cpp';
$declarations = [];
$entries = [];
foreach ($extensions as $extension) {
$moduleEntry = $extension->extensionModuleEntry;
$declarations[] = "extern \"C\" zend_module_entry {$moduleEntry};";
$entries[] = " &{$moduleEntry},";
}
if ($registerProject) {
$namespace = 'typephp_project_' . $targetName;
$moduleEntry = 'typephp_' . $targetName . '_module_entry';
$declarations[] = "namespace {$namespace} { extern zend_module_entry {$moduleEntry}; }";
$entries[] = " &{$namespace}::{$moduleEntry},";
}
$count = count($entries);
$storageSize = max(1, $count);
$contents = "#include <php_nano_extension.h>\n\n"
. implode("\n", $declarations) . "\n\n"
. "extern \"C\" zend_result php_nano_startup_composer_extensions() {\n"
. " static zend_module_entry *extensions[{$storageSize}] = {\n"
. implode("\n", $entries) . "\n"
. " };\n"
. " return php_nano_startup_extensions(extensions, {$count});\n"
. "}\n\n"
. "extern \"C\" void php_nano_shutdown_composer_extensions() {\n"
. " php_nano_shutdown_extensions();\n"
. "}\n";
if (!is_file($path) || file_get_contents($path) !== $contents) {
if (file_put_contents($path, $contents) === false) {
throw new RuntimeException("Unable to write Nano extension registry: {$path}");
}
}
return $path;
}
}

@ -138,6 +138,13 @@ trait NativeBuildConfigurationTrait
protected function getIncludePaths(): array protected function getIncludePaths(): array
{ {
if ($this->isNanoMode()) {
return array_values(array_unique([
...$this->nanoRuntimeIncludePaths,
$this->getBuildDir() . '/include',
]));
}
$sdkDir = $this->getTargetSdkDir(); $sdkDir = $this->getTargetSdkDir();
if ($sdkDir !== null) { if ($sdkDir !== null) {
return [ return [
@ -179,6 +186,10 @@ trait NativeBuildConfigurationTrait
protected function getLibraryPaths(): array protected function getLibraryPaths(): array
{ {
if ($this->isNanoMode()) {
return [];
}
$sdkDir = $this->getTargetSdkDir(); $sdkDir = $this->getTargetSdkDir();
if ($sdkDir !== null) { if ($sdkDir !== null) {
return [$sdkDir . '/lib']; return [$sdkDir . '/lib'];
@ -207,6 +218,10 @@ trait NativeBuildConfigurationTrait
*/ */
protected function getLibraries(): array protected function getLibraries(): array
{ {
if ($this->isNanoMode()) {
return [];
}
$sdkDir = $this->getFullStaticSdkDir(); $sdkDir = $this->getFullStaticSdkDir();
if ($sdkDir !== null) { if ($sdkDir !== null) {
// Fully-static: both archives are self-contained. libphpx.a comes // Fully-static: both archives are self-contained. libphpx.a comes

@ -33,6 +33,18 @@ trait NativeCommandOptionsTrait
$userDefines[] = 'TYPEPHP_NO_MAIN=1'; $userDefines[] = 'TYPEPHP_NO_MAIN=1';
$userDefines[] = $this->getLibraryExportsMacroName() . '=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 = [ $values = [
'include_paths' => $includePaths, 'include_paths' => $includePaths,
@ -47,6 +59,8 @@ trait NativeCommandOptionsTrait
'prof_output' => $this->targetName . '.prof', 'prof_output' => $this->targetName . '.prof',
'user_defines' => $userDefines, 'user_defines' => $userDefines,
'lto' => $this->enableLto, 'lto' => $this->enableLto,
'section_gc' => $this->isNanoMode(),
'wasi_exceptions' => $this->isNanoMode() && $this->isWasiTarget(),
]; ];
if ($this->debug && $this->isWindows()) { if ($this->debug && $this->isWindows()) {
@ -90,7 +104,8 @@ trait NativeCommandOptionsTrait
protected function getCCompileCommandOptions(): CompileOptions protected function getCCompileCommandOptions(): CompileOptions
{ {
$options = $this->getCommonCompileCommandOptions(); $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 protected function getPrecompiledHeaderCompileCommandOptions(): CompileOptions
@ -145,6 +160,15 @@ trait NativeCommandOptionsTrait
$targetPlatform = $this->getFullStaticTargetTriple(); $targetPlatform = $this->getFullStaticTargetTriple();
$ldflags = trim('-static -B ' . escapeshellarg($this->getFullStaticMuslDir()) . ' ' . $ldflags); $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 = [ $options = [
'library_paths' => $libraryPaths, 'library_paths' => $libraryPaths,
@ -158,7 +182,9 @@ trait NativeCommandOptionsTrait
'target_platform' => $targetPlatform, 'target_platform' => $targetPlatform,
]; ];
$rpaths = $this->getPlatform()->getDefaultRpaths($this->getPhpxDir(), $this->getPhpDir()); $rpaths = $this->isNanoMode()
? []
: $this->getPlatform()->getDefaultRpaths($this->getPhpxDir(), $this->getPhpDir());
if (!empty($rpaths)) { if (!empty($rpaths)) {
$options['rpath'] = $rpaths; $options['rpath'] = $rpaths;
} }

@ -0,0 +1,105 @@
<?php
namespace TypePhp\Build;
use RuntimeException;
/** Enforces PHP Nano's link-input and forbidden host-capability boundary. */
final class NativeDependencyAuditor
{
/**
* Reviewed native exceptions outside ISO C/C++ and POSIX.1-2008.
* POSIX functions do not need to be repeated in this list.
*/
public const array NON_POSIX_HOST_FUNCTION_ALLOWLIST = [
'flock',
];
/** @param list<string> $flags */
public function assertLinkFlags(string $target, array $flags): void
{
$allowedLibraries = $target === 'wasip2'
? ['-lsetjmp', '-lunwind']
: [];
foreach ($flags as $flag) {
if (str_starts_with($flag, '-l') && !in_array($flag, $allowedLibraries, true)) {
throw new RuntimeException(
"PHP Nano target must not link external library `{$flag}`"
);
}
if (str_starts_with($flag, '-L')) {
throw new RuntimeException(
"PHP Nano target must not add external library path `{$flag}`"
);
}
}
}
public function assertUndefinedSymbols(string $target, string $nmOutput): void
{
$forbidden = [];
foreach (preg_split('/\R/', $nmOutput) ?: [] as $line) {
if (preg_match('/\bU\s+([^\s]+)\s*$/', trim($line), $match) !== 1) {
continue;
}
$symbol = preg_replace('/@.*$/', '', $match[1]) ?? $match[1];
$symbol = ltrim($symbol, '_');
if ($this->isForbiddenSymbol($symbol, $target)) {
$forbidden[$symbol] = true;
}
}
if ($forbidden !== []) {
$symbols = array_keys($forbidden);
sort($symbols);
throw new RuntimeException(
'PHP Nano artifact imports forbidden host capabilities: '
. implode(', ', $symbols)
);
}
}
private function isForbiddenSymbol(string $symbol, string $target): bool
{
if ($target === 'wasip2') {
// LLVM nm prints Preview 1 imports using either their plain field
// name or a generated __imported_wasi_snapshot_preview1_* name.
$symbol = preg_replace(
'/^(?:imported_)?wasi_snapshot_preview1_/',
'',
$symbol,
) ?? $symbol;
/* C/C++ standard-library implementations may use clocks, entropy,
* environment, and WASI filesystem imports. Network remains out. */
if ($symbol === 'clock_time_get' || $symbol === 'poll_oneoff' || $symbol === 'random_get') {
return false;
}
if (str_starts_with($symbol, 'sock_')) {
return true;
}
if ($symbol === 'proc_raise' || $symbol === 'sched_yield') {
return true;
}
} elseif (in_array($symbol, self::NON_POSIX_HOST_FUNCTION_ALLOWLIST, true)) {
return false;
}
return preg_match(
'/^(?:'
. 'fork|vfork|exec(?:l|le|lp|lpe|v|ve|vp|vpe)?|system|popen|pclose|'
. 'posix_spawn(?:p)?|wait|waitpid|kill|'
. 'dlopen|dlsym|dlclose|dlerror|'
. 'socket|socketpair|connect|bind|listen|accept|accept4|send|sendto|sendmsg|'
. 'recv|recvfrom|recvmsg|shutdown|getsockopt|setsockopt|getpeername|getsockname|'
. 'getaddrinfo|freeaddrinfo|getnameinfo|gethostname|gethostbyaddr|gethostbyname|'
. 'gethostbynamel|inet_addr|inet_ntoa|inet_ntop|inet_pton|res_init|res_query|'
. 'pipe|pipe2|ioctl|select|pselect|poll|ppoll|'
. 'setenv|putenv|unsetenv|'
. 'openlog|closelog|syslog|'
. 'signal|sigaction|sigprocmask|pthread_sigmask|raise|alarm|setitimer|'
. 'mmap|munmap|mprotect|madvise|'
. 'syscall'
. ')$/',
$symbol,
) === 1;
}
}

@ -0,0 +1,481 @@
<?php
namespace TypePhp\Build;
use RuntimeException;
use TypePhp\Translator;
/**
* Direct C and C++ source composer for a TypePHP native project.
*
* It intentionally does not generate or invoke CMake and does not create
* intermediate runtime/PHPX libraries.
*/
final class NativeSourceProjectBuilder
{
private bool $progressLineActive = false;
private int $progressLineWidth = 0;
public function __construct(private readonly bool $lineProgress = false)
{
}
/** @return array{output: string, sourceCount: int, compiledCount: int} */
public function build(NativeSourceProjectConfig $project): array
{
if ($project->target === 'native' && PHP_OS_FAMILY === 'Windows') {
throw new RuntimeException(
'php-nano does not target Windows; use TypePHP --nano with the full PHP/PHPX DLL runtime'
);
}
$this->writeProgress("Preparing Nano {$project->target} build: {$project->name}");
$compiler = $this->resolveExecutable($project->compiler);
$cCompiler = $this->resolveExecutable($project->cCompiler);
$this->ensureDirectory($project->buildDir);
$objectDir = $project->buildDir . DIRECTORY_SEPARATOR . 'objects';
$this->ensureDirectory($objectDir);
$generatedSources = [];
$generatedIncludeDir = null;
if ($project->phpSources !== []) {
$this->writeProgress(
'Generating C++ from ' . count($project->phpSources) . ' TypePHP source file(s)'
);
$generatedDir = $project->buildDir . DIRECTORY_SEPARATOR . 'generated';
$translator = Translator::getInstance();
$phpFiles = $translator->prepareNanoSources(
$project->phpSources,
$this->projectSymbolName($project),
$generatedDir,
$project->target === 'wasip2',
);
$generatedSources = $translator->convert($phpFiles);
$generatedIncludeDir = $generatedDir . DIRECTORY_SEPARATOR . 'include';
}
$composition = (new NanoSourceComposer())->compose(
$project->buildDir,
$this->projectSymbolName($project),
$project->phpSources !== [],
);
$packages = $composition['packages'];
$extensionRegistry = $composition['registry'];
$sources = [...$project->sources, ...$generatedSources];
$includeDirs = [];
if ($generatedIncludeDir !== null) {
$includeDirs[] = $generatedIncludeDir;
}
array_push($sources, ...$composition['packageSources']);
array_push($includeDirs, ...$composition['includeDirs']);
$sources[] = $extensionRegistry;
$sources = array_values(array_unique($sources));
$includeDirs = array_values(array_unique($includeDirs));
$sourceCount = count($sources);
$this->writeProgress(
"Resolved {$sourceCount} C/C++ source file(s) from " . count($packages) . ' Composer package(s)'
);
$strictSources = array_fill_keys(
[...$project->sources, ...$generatedSources, $extensionRegistry],
true,
);
$objects = [];
$compiledCount = 0;
foreach ($sources as $sourceIndex => $source) {
$isCSource = strtolower(pathinfo($source, PATHINFO_EXTENSION)) === 'c';
$sourceCompiler = $isCSource ? $cCompiler : $compiler;
$object = $objectDir . DIRECTORY_SEPARATOR
. pathinfo($source, PATHINFO_FILENAME) . '-' . substr(sha1($source), 0, 12) . '.o';
$dependencyFile = $object . '.d';
$signatureFile = $object . '.command';
$command = [
$sourceCompiler,
$isCSource ? '-std=c11' : '-std=c++17',
...$this->compileModeFlags($project),
...(isset($strictSources[$source]) ? ['-Wall', '-Wextra', '-Wpedantic'] : []),
'-DTYPEPHP_NATIVE=1',
'-DPHP_NANO=1',
'-DPHPX_NANO=1',
'-D_POSIX_C_SOURCE=200809L',
'-ffunction-sections',
'-fdata-sections',
// Package headers are passed with -isystem to suppress upstream
// warnings, but they are still build inputs. -MD keeps them in
// the depfile; -MMD would silently leave stale Nano/PHPX objects.
'-MD',
'-MF',
$dependencyFile,
];
foreach ($includeDirs as $includeDir) {
$command[] = '-isystem';
$command[] = $includeDir;
}
array_push($command, '-c', $source, '-o', $object);
$signature = hash('sha256', implode("\0", $command));
if ($this->needsCompile($object, $dependencyFile, $signatureFile, $signature)) {
$this->writeCompileProgress(
$sourceIndex + 1,
$sourceCount,
$this->sourceLabel($source, $project, $packages),
);
$this->run($command, dirname($project->file));
$this->writeFile($signatureFile, $signature . "\n");
++$compiledCount;
}
$objects[] = $object;
}
$this->finishProgressLine();
$cachedCount = $sourceCount - $compiledCount;
$this->writeProgress("Compilation complete: {$compiledCount} compiled, {$cachedCount} cached");
$this->ensureDirectory(dirname($project->output));
$linkCommand = [
$compiler,
...$objects,
...$this->linkModeFlags($project),
'-o',
$project->output,
];
$auditor = new NativeDependencyAuditor();
$auditor->assertLinkFlags($project->target, $this->linkModeFlags($project));
// Link metadata is an intermediate build artifact. Keep it out of the
// invocation/output directory when the executable defaults to ./name.
$linkSignatureFile = $project->buildDir . DIRECTORY_SEPARATOR
. 'link-' . substr(sha1($project->output), 0, 12) . '.command';
$linkSignature = hash('sha256', implode("\0", $linkCommand));
if ($compiledCount !== 0
|| $this->needsLink($project->output, $objects, $linkSignatureFile, $linkSignature)) {
$this->writeProgress('Linking: ' . $project->output);
$this->run($linkCommand, dirname($project->file));
$this->writeFile($linkSignatureFile, $linkSignature . "\n");
} else {
$this->writeProgress('Link is up to date: ' . $project->output);
}
$this->writeProgress('Auditing native dependencies');
$this->auditArtifact($project, $objects, $compiler, $auditor);
return [
'output' => $project->output,
'sourceCount' => $sourceCount,
'compiledCount' => $compiledCount,
];
}
/** @param list<ComposerNativePackage> $packages */
private function sourceLabel(
string $source,
NativeSourceProjectConfig $project,
array $packages,
): string {
foreach ($packages as $package) {
$prefix = rtrim($package->installPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (str_starts_with($source, $prefix)) {
return $package->name . '/' . str_replace(
DIRECTORY_SEPARATOR,
'/',
substr($source, strlen($prefix)),
);
}
}
$buildPrefix = rtrim($project->buildDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (str_starts_with($source, $buildPrefix)) {
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($source, strlen($buildPrefix)));
return preg_replace('#/+#', '/', $relative) ?? $relative;
}
return $source;
}
private function writeCompileProgress(int $current, int $total, string $source): void
{
$message = "Compiling [{$current}/{$total}] {$source}";
if ($this->lineProgress || !function_exists('stream_isatty') || !stream_isatty(STDOUT)) {
$this->writeProgress($message);
return;
}
$padding = max(0, $this->progressLineWidth - strlen($message));
fwrite(STDOUT, "\r{$message}" . str_repeat(' ', $padding));
fflush(STDOUT);
$this->progressLineActive = true;
$this->progressLineWidth = strlen($message);
}
private function writeProgress(string $message): void
{
$this->finishProgressLine();
fwrite(STDOUT, $message . PHP_EOL);
fflush(STDOUT);
}
private function finishProgressLine(): void
{
if (!$this->progressLineActive) {
return;
}
fwrite(STDOUT, PHP_EOL);
fflush(STDOUT);
$this->progressLineActive = false;
$this->progressLineWidth = 0;
}
private function projectSymbolName(NativeSourceProjectConfig $project): string
{
return str_replace(['-', '.'], '_', $project->name);
}
private function needsCompile(
string $object,
string $dependencyFile,
string $signatureFile,
string $signature,
): bool {
if (!is_file($object) || !is_file($dependencyFile)
|| trim((string) @file_get_contents($signatureFile)) !== $signature) {
return true;
}
$objectTime = filemtime($object);
if ($objectTime === false) {
return true;
}
foreach ($this->readDependencies($dependencyFile) as $dependency) {
$dependencyTime = filemtime($dependency);
if ($dependencyTime === false || $dependencyTime > $objectTime) {
return true;
}
}
return false;
}
/** @param list<string> $objects */
private function needsLink(
string $output,
array $objects,
string $signatureFile,
string $signature,
): bool {
if (!is_file($output)
|| trim((string) @file_get_contents($signatureFile)) !== $signature) {
return true;
}
$outputTime = filemtime($output);
if ($outputTime === false) {
return true;
}
foreach ($objects as $object) {
$objectTime = filemtime($object);
if ($objectTime === false || $objectTime > $outputTime) {
return true;
}
}
return false;
}
/** @return list<string> */
private function readDependencies(string $dependencyFile): array
{
$contents = file_get_contents($dependencyFile);
if (!is_string($contents)) {
return [];
}
$contents = preg_replace('/\\\\\r?\n/', ' ', $contents) ?? '';
$separator = strpos($contents, ': ');
if ($separator === false) {
return [];
}
$dependencies = trim(substr($contents, $separator + 2));
if ($dependencies === '') {
return [];
}
$entries = preg_split('/(?<!\\\\)\s+/', $dependencies) ?: [];
return array_values(array_filter(array_map(
static fn(string $entry): string => str_replace(['\\ ', '\\\\'], [' ', '\\'], $entry),
$entries,
), static fn(string $entry): bool => $entry !== ''));
}
private function writeFile(string $path, string $contents): void
{
if (file_put_contents($path, $contents) === false) {
throw new RuntimeException("Unable to write native build metadata: {$path}");
}
}
public function runOutput(NativeSourceProjectConfig $project): never
{
$command = $project->target === 'wasip2'
? [$this->resolveExecutable('wasmtime'), $project->output]
: [$project->output];
$status = $this->run($command, dirname($project->file), false);
exit($status);
}
/** @return list<string> */
private function compileModeFlags(NativeSourceProjectConfig $project): array
{
$flags = $project->buildType === 'debug'
? ['-O0', '-g']
: ['-O2', '-DNDEBUG'];
if ($project->target === 'wasip2') {
array_push(
$flags,
'-fwasm-exceptions',
'-DZEND_MM_ERROR=0',
'-mllvm',
'-wasm-enable-sjlj',
'-mllvm',
'-wasm-use-legacy-eh=false',
);
}
return $flags;
}
/** @return list<string> */
private function linkModeFlags(NativeSourceProjectConfig $project): array
{
if ($project->target === 'wasip2') {
// WASI SDK ships libunwind as the exception ABI companion of its
// libc++; it is a toolchain runtime, not an application dependency.
return [
'-fwasm-exceptions',
'-lsetjmp',
'-lunwind',
'-Wl,--gc-sections',
];
}
return PHP_OS_FAMILY === 'Darwin'
? ['-Wl,-dead_strip']
: (PHP_OS_FAMILY === 'Windows' ? [] : ['-Wl,--gc-sections']);
}
private function resolveExecutable(string $command): string
{
if (str_contains($command, '/') || str_contains($command, '\\')) {
$path = realpath($command);
if ($path === false || !is_executable($path)) {
throw new RuntimeException("Native build tool is not executable: {$command}");
}
return $path;
}
$path = getenv('PATH');
foreach (explode(PATH_SEPARATOR, is_string($path) ? $path : '') as $directory) {
if ($directory === '') {
continue;
}
$candidate = $directory . DIRECTORY_SEPARATOR . $command;
if (is_file($candidate) && is_executable($candidate)) {
return $candidate;
}
}
throw new RuntimeException("Native build tool was not found on PATH: {$command}");
}
/** @param list<string> $objects */
private function auditArtifact(
NativeSourceProjectConfig $project,
array $objects,
string $compiler,
NativeDependencyAuditor $auditor,
): void {
if ($project->target === 'wasip2') {
$nm = dirname($compiler) . DIRECTORY_SEPARATOR . 'llvm-nm';
if (!is_executable($nm)) {
$nm = $this->resolveExecutable('llvm-nm');
}
$auditor->assertUndefinedSymbols(
$project->target,
$this->runCapture([$nm, '--undefined-only', ...$objects], dirname($project->file)),
);
$auditCore = $project->buildDir . DIRECTORY_SEPARATOR . '.native-audit-core.wasm';
try {
$this->run([
$compiler,
...$objects,
...$this->linkModeFlags($project),
'-Wl,--wasi-adapter=none',
'-Wl,--skip-wit-component',
'-o',
$auditCore,
], dirname($project->file));
$auditor->assertUndefinedSymbols(
$project->target,
$this->runCapture([$nm, '--undefined-only', $auditCore], dirname($project->file)),
);
} finally {
if (is_file($auditCore)) {
unlink($auditCore);
}
}
return;
}
$nm = $this->resolveExecutable('nm');
$auditor->assertUndefinedSymbols(
$project->target,
$this->runCapture([$nm, '-u', ...$objects], dirname($project->file)),
);
$auditor->assertUndefinedSymbols(
$project->target,
$this->runCapture(
[$nm, '-u', $project->output],
dirname($project->file),
),
);
}
private function ensureDirectory(string $directory): void
{
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new RuntimeException("Unable to create native build directory: {$directory}");
}
}
/** @param list<string> $command */
private function run(array $command, string $workingDirectory, bool $throw = true): int
{
$process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, $workingDirectory);
if (!is_resource($process)) {
throw new RuntimeException("Unable to start native build tool: {$command[0]}");
}
$status = proc_close($process);
if ($throw && $status !== 0) {
$this->finishProgressLine();
throw new RuntimeException(
'Native build command failed with status ' . $status . ': '
. implode(' ', array_map(escapeshellarg(...), $command))
);
}
return $status;
}
/** @param list<string> $command */
private function runCapture(array $command, string $workingDirectory): string
{
$process = proc_open(
$command,
[STDIN, ['pipe', 'w'], ['pipe', 'w']],
$pipes,
$workingDirectory,
);
if (!is_resource($process)) {
throw new RuntimeException("Unable to start native audit tool: {$command[0]}");
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$status = proc_close($process);
if ($status !== 0) {
throw new RuntimeException(
'Native audit command failed with status ' . $status . ': '
. implode(' ', array_map(escapeshellarg(...), $command))
. ($stderr === '' ? '' : "\n{$stderr}")
);
}
return (string) $stdout;
}
}

@ -0,0 +1,156 @@
<?php
namespace TypePhp\Build;
use DOMDocument;
use DOMElement;
use RuntimeException;
/** XML configuration for a source-composition native project. */
final readonly class NativeSourceProjectConfig
{
/**
* @param list<string> $phpSources
* @param list<string> $sources Native C and C++ sources.
*/
private function __construct(
public string $file,
public string $name,
public string $target,
public string $buildType,
public string $compiler,
public string $cCompiler,
public string $buildDir,
public string $output,
public array $phpSources,
public array $sources,
) {
}
public static function isNativeProject(string $path): bool
{
if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'xml' || !is_file($path)) {
return false;
}
$document = new DOMDocument();
if (!@$document->load($path, LIBXML_NONET | LIBXML_NOBLANKS)) {
return false;
}
return $document->documentElement?->tagName === 'project'
&& strtolower($document->documentElement->getAttribute('mode')) === 'native';
}
public static function load(string $path, ?string $buildDirOverride = null): self
{
$file = realpath($path);
if ($file === false) {
throw new RuntimeException("Native project does not exist: {$path}");
}
$document = new DOMDocument();
if (!@$document->load($file, LIBXML_NONET | LIBXML_NOBLANKS)) {
throw new RuntimeException("Unable to parse native project XML: {$file}");
}
$root = $document->documentElement;
if (!$root instanceof DOMElement || $root->tagName !== 'project'
|| strtolower($root->getAttribute('mode')) !== 'native') {
throw new RuntimeException('Native project root must be `<project mode="native">`');
}
$projectDir = dirname($file);
$name = trim($root->getAttribute('name'));
if ($name === '') {
$name = basename($projectDir);
}
if (preg_match('/^[A-Za-z0-9_.-]+$/', $name) !== 1) {
throw new RuntimeException("Invalid native project name: {$name}");
}
$target = strtolower(self::elementText($root, 'target') ?? 'native');
if (!in_array($target, ['native', 'wasip2'], true)) {
throw new RuntimeException('Native target must be `native` or `wasip2`');
}
$buildType = strtolower(self::elementText($root, 'build-type') ?? 'release');
if (!in_array($buildType, ['release', 'debug'], true)) {
throw new RuntimeException('Native build-type must be `release` or `debug`');
}
$compiler = self::elementText($root, 'compiler')
?? ($target === 'wasip2' ? 'wasm32-wasip2-clang++' : 'c++');
if ($compiler === '' || preg_match('/[\x00-\x1f]/', $compiler)) {
throw new RuntimeException('Invalid native compiler command');
}
$cCompiler = self::elementText($root, 'c-compiler')
?? ($target === 'wasip2' ? 'wasm32-wasip2-clang' : 'cc');
if ($cCompiler === '' || preg_match('/[\x00-\x1f]/', $cCompiler)) {
throw new RuntimeException('Invalid native C compiler command');
}
$configuredBuildDir = $buildDirOverride
?? self::elementText($root, 'build-dir')
?? 'build/native-' . $target;
$buildDir = self::absolutePath($projectDir, $configuredBuildDir);
$outputName = self::elementText($root, 'output')
?? ($target === 'wasip2' ? $name . '.wasm' : $name);
$output = self::absolutePath($buildDir, $outputName);
$phpSources = [];
$sources = [];
foreach ($root->getElementsByTagName('source') as $sourceNode) {
$source = trim($sourceNode->textContent);
if ($source === '') {
throw new RuntimeException('Native project contains an empty `<source>`');
}
$resolved = realpath(self::absolutePath($projectDir, $source));
if ($resolved === false || !is_file($resolved)) {
throw new RuntimeException("Native project source does not exist: {$source}");
}
$extension = strtolower(pathinfo($resolved, PATHINFO_EXTENSION));
if ($extension === 'php') {
$phpSources[] = $resolved;
continue;
}
if (!in_array($extension, ['c', 'cc', 'cpp', 'cxx'], true)) {
throw new RuntimeException(
"Native project source must be PHP, C, or C++: {$source}"
);
}
$sources[] = $resolved;
}
if ($phpSources === [] && $sources === []) {
throw new RuntimeException('Native project must contain at least one `<source>`');
}
return new self(
$file,
$name,
$target,
$buildType,
$compiler,
$cCompiler,
$buildDir,
$output,
array_values(array_unique($phpSources)),
array_values(array_unique($sources)),
);
}
private static function elementText(DOMElement $root, string $name): ?string
{
$nodes = $root->getElementsByTagName($name);
if ($nodes->length === 0) {
return null;
}
$value = trim($nodes->item(0)?->textContent ?? '');
return $value === '' ? null : $value;
}
private static function absolutePath(string $base, string $path): string
{
if ($path[0] === '/' || $path[0] === '\\'
|| preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1) {
return $path;
}
return $base . DIRECTORY_SEPARATOR . $path;
}
}

@ -19,6 +19,76 @@ use TypePhp\Platform\Windows;
trait SourcePipelineTrait 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<string> $files
* @return list<string>
*/
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 public function addFiles(array $files): void
{ {
$this->sourceDirs = array_merge($this->sourceDirs, $files); $this->sourceDirs = array_merge($this->sourceDirs, $files);
@ -79,6 +149,10 @@ trait SourcePipelineTrait
{ {
$files = $this->getFiles($path); $files = $this->getFiles($path);
// 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) { if ($this->isBuildModeEmbed() && $this->getPlatform() instanceof Linux) {
try { try {
$phpDir = (new LibPhpInstaller())->ensure($this->getPhpDir()) ?? $this->getPhpDir(); $phpDir = (new LibPhpInstaller())->ensure($this->getPhpDir()) ?? $this->getPhpDir();
@ -109,6 +183,7 @@ trait SourcePipelineTrait
if (defined('TYPEPHP_PHP_SCRIPT_ENTRY') && !($this->getPlatform() instanceof Wasi)) { if (defined('TYPEPHP_PHP_SCRIPT_ENTRY') && !($this->getPlatform() instanceof Wasi)) {
$this->validatePhpxLibrary(); $this->validatePhpxLibrary();
} }
}
$this->validateCompilerToolchain(); $this->validateCompilerToolchain();
@ -117,7 +192,8 @@ trait SourcePipelineTrait
// All Windows build modes depend on the PHPX import library and runtime. // All Windows build modes depend on the PHPX import library and runtime.
// Other platforms only run the existing checks in embedded build mode. // 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( foreach ($this->getPlatform()->getBuildLibraryWarnings(
$this->getPhpDir(), $this->getPhpDir(),
$this->getPhpxDir(), $this->getPhpxDir(),
@ -306,8 +382,12 @@ trait SourcePipelineTrait
// runtime data declarations // runtime data declarations
$this->genFunctionDeclarations($this->getIncludeDir() . "/php_{$this->targetName}_func_decl.h"); $this->genFunctionDeclarations($this->getIncludeDir() . "/php_{$this->targetName}_func_decl.h");
$this->genDataDeclarations($this->getIncludeDir() . "/php_{$this->targetName}_data_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(); $sourceFiles[] = $this->genExtension();
if ($this->isNanoMode()) {
$sourceFiles[] = $this->genNanoEntrypoint();
}
return $sourceFiles; return $sourceFiles;
} finally { } finally {

@ -200,9 +200,9 @@ class CompilerBase implements PropertyAccessContext
]; ];
/** /**
* APIs which cannot have the same semantics in Wasmtime and a browser. * APIs omitted by the smaller WASI capability profile. Keep the rejection
* Keep this list at the language boundary so a WASI build never degrades * at the language boundary so a direct call never degrades into a link
* into a link error or a browser-only implementation. * error or an internal ENOTSUP compatibility stub.
*/ */
private const array WASI_UNSUPPORTED_FUNCTIONS = [ private const array WASI_UNSUPPORTED_FUNCTIONS = [
'exec', 'exec',
@ -215,17 +215,31 @@ class CompilerBase implements PropertyAccessContext
'proc_terminate', 'proc_terminate',
'shell_exec', 'shell_exec',
'system', 'system',
'flock',
'umask',
'chown',
'chgrp',
'lchown',
'lchgrp',
'fsockopen', 'fsockopen',
'pfsockopen', 'pfsockopen',
'stream_socket_accept', 'stream_socket_accept',
'stream_socket_client', 'stream_socket_client',
'stream_socket_enable_crypto', 'stream_socket_enable_crypto',
'stream_socket_get_crypto_status',
'stream_socket_get_name', 'stream_socket_get_name',
'stream_socket_pair', 'stream_socket_pair',
'stream_socket_recvfrom', 'stream_socket_recvfrom',
'stream_socket_sendto', 'stream_socket_sendto',
'stream_socket_server', 'stream_socket_server',
'stream_socket_shutdown', 'stream_socket_shutdown',
'gethostbyaddr',
'gethostbyname',
'gethostbynamel',
'gethostname',
'dns_check_record',
'dns_get_mx',
'dns_get_record',
]; ];
private const array WASI_UNSUPPORTED_FUNCTION_PREFIXES = [ private const array WASI_UNSUPPORTED_FUNCTION_PREFIXES = [
@ -233,6 +247,89 @@ class CompilerBase implements PropertyAccessContext
'posix_', 'posix_',
'socket_', '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_RETURN = 1;
public const int DECL_TYPE_OF_PROPERTY = 2; public const int DECL_TYPE_OF_PROPERTY = 2;
public const int DECL_TYPE_OF_CONST = 3; 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 array $userDefines = []; // --define / -D: user-provided preprocessor macros
protected bool $enableLto = false; // --lto: enable Link Time Optimization (-flto) protected bool $enableLto = false; // --lto: enable Link Time Optimization (-flto)
protected bool $fullStatic = false; // --full-static: link against the bundled fully-static SDK 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<string> Include directories published by Nano Composer packages. */
protected array $nanoRuntimeIncludePaths = [];
/** @var array<string, true> 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 $file;
protected string $dir; protected string $dir;
@ -697,6 +804,16 @@ class CompilerBase implements PropertyAccessContext
return $target === 'wasm32-unknown-wasip2' || $target === 'wasm32-wasip2'; 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 protected function assertWasiFunctionSupported(NodeAbstract $expr, string $name): void
{ {
if (!$this->isWasiTarget()) { 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 public function isBuildModeBin(): bool
{ {
return $this->buildMode === self::BUILD_MODE_BIN; return $this->buildMode === self::BUILD_MODE_BIN;
@ -1600,9 +1754,11 @@ class CompilerBase implements PropertyAccessContext
{ {
$this->assertNotNativeObjectArrayKey($expr); $this->assertNotNativeObjectArrayKey($expr);
$key = $this->parseIdentifier($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() // 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()"; return $keepStringObject ? $key : "{$key}.str()";
} }
if ($this->isZeroLiteral($expr)) { if ($this->isZeroLiteral($expr)) {
@ -4925,6 +5081,9 @@ class CompilerBase implements PropertyAccessContext
protected function parseShellExec(Expr\ShellExec $expr): string 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()) { if ($this->isWasiTarget()) {
$this->fatalError($expr, 'Backtick shell execution is not supported by the WASI target'); $this->fatalError($expr, 'Backtick shell execution is not supported by the WASI target');
} }

@ -14,12 +14,82 @@ use TypePhp\Entity\ClassDef;
use TypePhp\Entity\FunctionDef; use TypePhp\Entity\FunctionDef;
use TypePhp\Entity\InterfaceDef; use TypePhp\Entity\InterfaceDef;
use TypePhp\Exception\Skip; use TypePhp\Exception\Skip;
use TypePhp\Analysis\ReferenceCaptureAnalyzer;
use TypePhp\Type;
trait CompilationStateTrait trait CompilationStateTrait
{ {
protected function addLocalVar(string $name, string $type): void 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<NodeAbstract>|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 protected function addTypedRefLocal(string $name, string $target, string $type): void
@ -67,6 +137,7 @@ trait CompilationStateTrait
if ($this->hasVar($name)) { if ($this->hasVar($name)) {
$this->fatalError($var, 'Duplicate variable `$' . $var->name . '`'); $this->fatalError($var, 'Duplicate variable `$' . $var->name . '`');
} }
$type = $this->context->varTypeDegradations[$name] ?? $type;
$this->context->staticVars[$name] = $type; $this->context->staticVars[$name] = $type;
// A static variable is actually a reference to a global variable. // A static variable is actually a reference to a global variable.
$globalVar = $this->escapeStaticVar($name); $globalVar = $this->escapeStaticVar($name);

@ -68,6 +68,8 @@ class FunctionContext
*/ */
public array $stdContainers = []; public array $stdContainers = [];
public array $localVars = []; public array $localVars = [];
/** @var array<string, string> Local variable => forced fallback storage type. */
public array $varTypeDegradations = [];
/** @var array<string, true> Locals explicitly created through std::int/float/bool. */ /** @var array<string, true> Locals explicitly created through std::int/float/bool. */
public array $explicitNativeTypeVars = []; public array $explicitNativeTypeVars = [];
/** @var array<string, string> C++ initializers folded into function-scope local declarations. */ /** @var array<string, string> C++ initializers folded into function-scope local declarations. */
@ -145,6 +147,7 @@ class FunctionContext
public function __construct() public function __construct()
{ {
$this->localVars = []; $this->localVars = [];
$this->varTypeDegradations = [];
$this->explicitNativeTypeVars = []; $this->explicitNativeTypeVars = [];
$this->localVarInitializers = []; $this->localVarInitializers = [];
$this->localClosureCandidates = []; $this->localClosureCandidates = [];

@ -143,6 +143,9 @@ trait ClosureGenerator
$this->inGeneratorBody = false; $this->inGeneratorBody = false;
$this->indentLevel = $entryIndent + 1; $this->indentLevel = $entryIndent + 1;
$body = $expr instanceof Expr\ArrowFunction ? $expr->expr : $expr->stmts;
$this->prepareReferenceCaptureDegradations($body);
$returnType = $expr->returnType; $returnType = $expr->returnType;
$returnTypeName = $returnType instanceof Node\Identifier $returnTypeName = $returnType instanceof Node\Identifier
? strtolower($returnType->name) ? strtolower($returnType->name)
@ -236,7 +239,12 @@ trait ClosureGenerator
$rawType = $this->getRawVarType($name); $rawType = $this->getRawVarType($name);
$valueType = Type::getReferencedType($rawType); $valueType = Type::getReferencedType($rawType);
if ($useItem->byRef) { 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) { if ($captureType === null) {
return null; return null;
} }
@ -450,6 +458,8 @@ trait ClosureGenerator
$this->context = new FunctionContext(); $this->context = new FunctionContext();
$this->context->inClosure = true; $this->context->inClosure = true;
$body = $expr instanceof Expr\ArrowFunction ? $expr->expr : $expr->stmts;
$this->prepareReferenceCaptureDegradations($body);
if (!$isGenerator if (!$isGenerator
&& ($expr->returnType instanceof NullableType && ($expr->returnType instanceof NullableType
|| $expr->returnType instanceof UnionType || $expr->returnType instanceof UnionType
@ -656,6 +666,9 @@ trait ClosureGenerator
$this->inGeneratorBody = true; $this->inGeneratorBody = true;
$this->indentLevel++; $this->indentLevel++;
$body = $expr instanceof Expr\ArrowFunction ? $expr->expr : $expr->stmts;
$this->prepareReferenceCaptureDegradations($body);
try { try {
foreach ($capturedNames as $i => $capturedName) { foreach ($capturedNames as $i => $capturedName) {
$code .= $this->getIndent() . Type::VAR . ' ' . $capturedName . ' = vars_.get(' . $i . ');' . PHP_EOL; $code .= $this->getIndent() . Type::VAR . ' ' . $capturedName . ' = vars_.get(' . $i . ');' . PHP_EOL;

@ -279,6 +279,8 @@ trait FiberGenerator
$this->inGeneratorBody = true; $this->inGeneratorBody = true;
$this->indentLevel++; $this->indentLevel++;
$this->prepareReferenceCaptureDegradations($v->stmts);
foreach ($functionDef->argInfoList as $i => $argInfo) { foreach ($functionDef->argInfoList as $i => $argInfo) {
$code .= $this->getIndent() . Type::VAR . ' ' . $argInfo->name . ' = vars_.get(' . $i . ');' . PHP_EOL; $code .= $this->getIndent() . Type::VAR . ' ' . $argInfo->name . ' = vars_.get(' . $i . ');' . PHP_EOL;
$this->addArgument($argInfo->name, Type::VAR); $this->addArgument($argInfo->name, Type::VAR);

@ -129,6 +129,12 @@ class Constants
]; ];
public const array COMPILER_OPTIONS = [ public const array COMPILER_OPTIONS = [
'nano' => [
'longPrefix' => 'nano',
'description' => 'Enable VM-free Nano policy (php-nano runtime outside Windows)',
'required' => false,
'noValue' => true,
],
'optimize' => [ 'optimize' => [
'prefix' => 'O', 'prefix' => 'O',
'longPrefix' => 'optimize', 'longPrefix' => 'optimize',

@ -1575,6 +1575,7 @@ trait NativeClassSupportTrait
protected function getNativeMethodParameterDeclarations( protected function getNativeMethodParameterDeclarations(
FunctionDef $function, FunctionDef $function,
?int $parameterCount = null, ?int $parameterCount = null,
bool $useDegradedArgumentNames = false,
): string ): string
{ {
$args = []; $args = [];
@ -1582,6 +1583,12 @@ trait NativeClassSupportTrait
? $function->argInfoList ? $function->argInfoList
: array_slice($function->argInfoList, 0, $parameterCount); : array_slice($function->argInfoList, 0, $parameterCount);
foreach ($arguments as $argument) { foreach ($arguments as $argument) {
if ($useDegradedArgumentNames
&& isset($this->context->varTypeDegradations[$argument->name])
) {
$argument = clone $argument;
$argument->name = $this->getDegradedArgumentStorageName($argument->name);
}
if ($argument->variadic) { if ($argument->variadic) {
$declaration = Type::ARRAY . ' ' . $argument->name; $declaration = Type::ARRAY . ' ' . $argument->name;
} else { } else {

@ -404,6 +404,15 @@ trait AssignOpTrait
} }
if ($this->isNativeObjectClass($rightClass)) { 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; $allowed = false;
if ($this->isVarExpr($left)) { if ($this->isVarExpr($left)) {
$leftName = $this->parseVariable($left); $leftName = $this->parseVariable($left);
@ -615,6 +624,12 @@ trait AssignOpTrait
if ($this->isStdClassExpr($right->class)) { if ($this->isStdClassExpr($right->class)) {
$stdMethod = strtolower($right->name->toString()); $stdMethod = strtolower($right->name->toString());
if (in_array($stdMethod, ['array', 'vector', 'map', 'orderedmap'], true)) { 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)) { if ($this->hasScopeGlobalVar($var) || $this->hasStaticVar($var)) {
$this->assertNativeStdContainerFunctionLocal($right); $this->assertNativeStdContainerFunctionLocal($right);
} }

@ -142,8 +142,13 @@ trait FunctionCallTrait
"Native classes do not support runtime class introspection; use {$replacement}", "Native classes do not support runtime class introspection; use {$replacement}",
); );
} }
if ($this->isInternalFunction($globalName)) { // 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->assertWasiFunctionSupported($expr, $globalName);
$this->assertNanoFunctionSupported($expr, $globalName);
if ($this->isInternalFunction($globalName)) {
$this->markInternalFunctionCallbackCall($globalName, $expr->args); $this->markInternalFunctionCallbackCall($globalName, $expr->args);
} }
if (in_array($globalName, Constants::UNSUPPORTED_FUNCTIONS, true)) { if (in_array($globalName, Constants::UNSUPPORTED_FUNCTIONS, true)) {

@ -445,7 +445,7 @@ trait MethodCallTrait
} }
if ($type === Type::BIGFLOAT) { if ($type === Type::BIGFLOAT) {
if ($argType === Type::INT) { if ($argType === Type::INT) {
return 'php::toBigFloat(' . $valueExpr . ')'; return 'php::toBigFloat(php::toInt(' . $valueExpr . '))';
} }
if ($argType === Type::FLOAT) { if ($argType === Type::FLOAT) {
return 'php::toBigFloat(' . $valueExpr . ')'; return 'php::toBigFloat(' . $valueExpr . ')';

@ -125,7 +125,7 @@ trait TypeConversionTrait
protected function convertBigFloatExpr(string $expr, string $fromType = ''): string protected function convertBigFloatExpr(string $expr, string $fromType = ''): string
{ {
if ($fromType === Type::INT) { if ($fromType === Type::INT) {
return 'php::toBigFloat(' . $expr . ')'; return 'php::toBigFloat(php::toInt(' . $expr . '))';
} }
if ($fromType === Type::FLOAT) { if ($fromType === Type::FLOAT) {
return 'php::toBigFloat(' . $expr . ')'; return 'php::toBigFloat(' . $expr . ')';

@ -430,9 +430,9 @@ trait UniversalMethodCall
} }
protected const array TO_CONVERT_FN = [ protected const array TO_CONVERT_FN = [
Type::BIGINT => ['toInt' => 'php::BigInt::toInt', 'toFloat' => 'php::BigInt::toFloat', 'toString' => 'php::BigInt::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'], 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'], 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 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); $this->validateUniversalMethodArgs($expr, $method, $def, $isVar);
return match ($def['handler']) { return match ($def['handler']) {

@ -23,6 +23,7 @@ use TypePhp\Exception\SyntaxError;
use TypePhp\Transform\PropertyHookLowering; use TypePhp\Transform\PropertyHookLowering;
use TypePhp\Transform\CompileTimeAttribute; use TypePhp\Transform\CompileTimeAttribute;
use TypePhp\Transform\NativeClassAttributeLowering; use TypePhp\Transform\NativeClassAttributeLowering;
use TypePhp\Transform\NanoSyntaxValidationVisitor;
use TypePhp\Transform\PrinterLowering; use TypePhp\Transform\PrinterLowering;
use TypePhp\Transform\ArrayableLowering; use TypePhp\Transform\ArrayableLowering;
use TypePhp\Transform\ClassFieldSelection; use TypePhp\Transform\ClassFieldSelection;
@ -324,6 +325,18 @@ class Preprocessor extends CompilerBase
$info = pathinfo($cppFile); $info = pathinfo($cppFile);
$ext = $this->getPlatform()->getObjectExtension(); $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 // Keep the same path separator as cppFile
$normalizedFile = str_replace('\\', '/', $cppFile); $normalizedFile = str_replace('\\', '/', $cppFile);
$normalizedMiscDir = str_replace('\\', '/', $this->getPhpxDir() . '/src/misc/'); $normalizedMiscDir = str_replace('\\', '/', $this->getPhpxDir() . '/src/misc/');
@ -400,6 +413,12 @@ class Preprocessor extends CompilerBase
$traverser = new NodeTraverser(); $traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); $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( $traverser->addVisitor(new VoidCastValidationVisitor(
fn (Node $node, string $message) => $this->fatalError($node, $message), fn (Node $node, string $message) => $this->fatalError($node, $message),
)); ));

@ -0,0 +1,72 @@
<?php
namespace TypePhp\Transform;
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
/** Reject syntax whose semantics require the runtime PHP compiler or Zend VM. */
final class NanoSyntaxValidationVisitor extends NodeVisitorAbstract
{
private const array UNSUPPORTED_RUNTIME_CLASSES = [
'Fiber' => true,
'Generator' => true,
'ReflectionFiber' => true,
'ReflectionGenerator' => true,
];
/** @param callable(Node, string): never $fatal */
public function __construct(
private readonly mixed $fatal,
private readonly bool $phpNanoRuntime = true,
) {
}
public function enterNode(Node $node): ?Node
{
if ($this->phpNanoRuntime && $node instanceof Node\Name) {
$resolved = $node->getAttribute('resolvedName');
$className = $resolved instanceof Node\Name
? $resolved->toString()
: ($node instanceof Node\Name\FullyQualified ? $node->toString() : null);
if ($className !== null && isset(self::UNSUPPORTED_RUNTIME_CLASSES[$className])) {
($this->fatal)(
$node,
"Class `{$className}` is not supported in nano mode because it requires Generator/Fiber runtime support",
);
}
}
if ($node instanceof Node\Expr\Eval_) {
($this->fatal)($node, '`eval` is not supported in nano mode');
}
if ($node instanceof Node\Expr\Include_) {
$keyword = match ($node->type) {
Node\Expr\Include_::TYPE_INCLUDE => 'include',
Node\Expr\Include_::TYPE_INCLUDE_ONCE => 'include_once',
Node\Expr\Include_::TYPE_REQUIRE => 'require',
Node\Expr\Include_::TYPE_REQUIRE_ONCE => 'require_once',
};
($this->fatal)($node, "`{$keyword}` is not supported in nano mode");
}
if ($node instanceof Node\Expr\ShellExec) {
($this->fatal)($node, 'Backtick shell execution is not supported in nano mode');
}
if ($node instanceof Node\Stmt\Class_ && $node->name === null) {
($this->fatal)($node, 'Anonymous classes are not supported in nano mode');
}
if ($this->phpNanoRuntime
&& ($node instanceof Node\Expr\Yield_ || $node instanceof Node\Expr\YieldFrom)) {
($this->fatal)(
$node,
'Fiber and Generator are not supported in nano mode because C++17 has no standard stack-switching API',
);
}
return null;
}
}

@ -18,6 +18,9 @@ use TypePhp\Build\CompileOptions;
use TypePhp\Build\FileScanner; use TypePhp\Build\FileScanner;
use TypePhp\Build\NativeCommandOptionsTrait; use TypePhp\Build\NativeCommandOptionsTrait;
use TypePhp\Build\NativeBuilder; use TypePhp\Build\NativeBuilder;
use TypePhp\Build\NanoBuildBackend;
use TypePhp\Build\NativeDependencyAuditor;
use TypePhp\Build\NanoSourceComposer;
use TypePhp\Build\PrecompiledHeaderManager; use TypePhp\Build\PrecompiledHeaderManager;
use TypePhp\Build\SourcePipelineTrait; use TypePhp\Build\SourcePipelineTrait;
use TypePhp\Build\WasmInterfaceGenerator; use TypePhp\Build\WasmInterfaceGenerator;
@ -54,6 +57,7 @@ use TypePhp\Transform\ConstructorLowering;
use TypePhp\Transform\ConstantExpressionValidationVisitor; use TypePhp\Transform\ConstantExpressionValidationVisitor;
use TypePhp\Transform\PropertyHookLowering; use TypePhp\Transform\PropertyHookLowering;
use TypePhp\Transform\RuntimeAttributeFactoryLowering; use TypePhp\Transform\RuntimeAttributeFactoryLowering;
use TypePhp\Transform\NanoSyntaxValidationVisitor;
use TypePhp\Transform\VoidCastValidationVisitor; use TypePhp\Transform\VoidCastValidationVisitor;
use PhpParser\Modifiers; use PhpParser\Modifiers;
use PhpParser\Node; use PhpParser\Node;
@ -153,6 +157,12 @@ class Translator extends Preprocessor
} }
unset($this->internalFunctions[self::ENTRY_FUNCTION]); unset($this->internalFunctions[self::ENTRY_FUNCTION]);
$this->internalConstants = $this->loadInternalConstants(); $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')) { if ($this->climate->arguments->defined('help')) {
$this->showUsage(); $this->showUsage();
exit(0); exit(0);
@ -162,11 +172,6 @@ class Translator extends Preprocessor
exit(0); 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. // Detect the OS, the compiler, and (on Windows) the PHP lib files.
$this->detectPlatform(); $this->detectPlatform();
@ -350,12 +355,34 @@ class Translator extends Preprocessor
global $argv; global $argv;
$cmd = $argv[0]; $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->bold('USAGE:');
$climate->tab()->out($cmd . ' <file/dir/config.yml> [options]'); $climate->tab()->out($cmd . ' <input> [compilation options] [-- <program arguments...>]');
$climate->tab()->out($cmd . ' <subcommand> [arguments]');
$climate->br(); $climate->br();
$climate->bold('ARGUMENTS:'); $climate->bold('ARGUMENTS:');
$climate->tab()->out('<file> Input PHP file/directory/YAML config to compile'); $printEntries([
['<input>', 'PHP file, source directory, YAML config, or native project.xml'],
['-- <arguments...>', 'Arguments passed to the program when used with --run'],
]);
$climate->br();
$climate->bold('SUBCOMMANDS:');
$printEntries([
['--gen-python-helper <module> [--output-dir <dir>]', 'Generate a Python namespace IDE helper'],
['--convert-python-to-php <file.py>', 'Convert Python source to TypePHP source'],
['--generate-completion=bash', 'Generate a Bash completion script'],
]);
$climate->br(); $climate->br();
$climate->bold('EXAMPLES:'); $climate->bold('EXAMPLES:');
@ -364,42 +391,48 @@ class Translator extends Preprocessor
$climate->tab()->out($cmd . ' project/config.yml -O2'); $climate->tab()->out($cmd . ' project/config.yml -O2');
$climate->tab()->out($cmd . ' my-ext/ -O2 -o myapp -m ext'); $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 . ' app.php -r -O2 -- --flag1 value1');
$climate->tab()->out($cmd . ' --gen-python-helper builtins');
$climate->br(); $climate->br();
$climate->bold('OPTIONS:'); $climate->bold('COMPILATION OPTIONS:');
$climate->tab()->out('-O <level> Optimization level (0-3, default: 0)'); $printEntries([
$climate->tab()->out('--profile Enable performance profiling (adds -lprofiler, forces recompile)'); ['-O, --optimize <level>', 'Optimization level (0-3, default: 0)'],
$climate->tab()->out('-d, --debug Enable debug mode (auto-disable optimizations, add debug symbols)'); ['--profile', 'Enable performance profiling (adds -lprofiler, forces recompile)'],
$climate->tab()->out('-o, --output <file> Output binary name (default: input basename)'); ['-d, --debug', 'Enable debug mode (disables optimizations and adds debug symbols)'],
$climate->tab()->out('-v, --version Show version'); ['-o, --output <file>', 'Output name or path (default: input basename)'],
$climate->tab()->out('-h, --help Show this help message'); ['-f, --force', 'Force recompilation and ignore the object cache'],
$climate->tab()->out('-f, --force Force recompile phpx misc files (ignore cache)'); ['-m, --mode <mode>', 'Build mode: bin, lib, or ext (default: bin)'],
$climate->tab()->out('-m, --mode <mode> Compilation mode: bin (binary), lib (shared library), or ext (PHP extension); default: bin'); ['-r, --run', 'Run the compiled binary after a successful build'],
$climate->tab()->out('-r, --run Run the compiled binary after build'); ['-j, --job <num>', 'Number of parallel compilation jobs (default: 4)'],
$climate->tab()->out('-j, --job <num> Number of parallel compilation jobs (default: 4)'); ['--cxx-std <ver>', 'C++ standard version (default: c++17)'],
$climate->tab()->out('--cxx-std <ver> C++ standard version (c++17, c++20, etc., default: c++17)'); ['--compiler <cmd>', 'C++ compiler command (for example /usr/bin/clang)'],
$climate->tab()->out('--compiler <cmd> C++ compiler command to use (e.g. --compiler=/usr/bin/clang)'); ['--march <arch>', 'Target CPU instruction set (for example native or armv8-a)'],
$climate->tab()->out('--march <arch> Target CPU instruction set (e.g. native, x86-64-v3, armv8-a)'); ['--target-platform <triple>', 'Cross-compilation target triple'],
$climate->tab()->out('--target-platform <triple> Cross-compilation target triple (e.g. aarch64-linux-gnu)'); ['--wasm[=browser|component]', 'Build WASI component (default) or browser output'],
$climate->tab()->out('--wasm[=profile] Build WASI component (default) or browser output'); ['--nano', 'Use the Nano policy and php-nano runtime outside Windows'],
$climate->tab()->out('--gen-python-helper <module> [--output-dir <dir>] Generate a Python namespace IDE helper'); ['--full-static', 'Link fully statically against the bundled SDK'],
$climate->tab()->out('--convert-python-to-php <file.py> Convert Python source to TypePHP source'); ['--lto', 'Enable Link Time Optimization (-flto)'],
$climate->tab()->out('--generate-completion=bash Generate Bash completion script'); ['--no-literal-strings', 'Disable literal string optimization'],
$climate->tab()->out('--lto Enable Link Time Optimization (-flto)'); ['--php-version <ver>', 'Accepted PHP language version (8.4-8.5, default: 8.5)'],
$climate->tab()->out('--no-literal-strings Disable literal strings optimization'); ['--no-progress', 'Print one compilation line per file instead of a progress bar'],
$climate->tab()->out('--php-version <ver> PHP language version to accept (8.4-8.5, default: 8.5)'); ['--no-console', 'Hide the console window (Windows GUI applications only)'],
$climate->tab()->out('--no-progress Disable progress bar, output per-file compilation progress line by line'); ['--sanitize <type>', 'Enable a sanitizer such as address or undefined'],
$climate->tab()->out('--no-console Hide console window (Windows only, GUI application)'); ['--build-dir <dir>', 'Directory for generated C++ and build files'],
$climate->tab()->out('--no-color Disable ANSI color output'); ['--dry', 'Generate C++ code without compiling or linking'],
$climate->tab()->out('--sanitize <type> Enable sanitizers (address, undefined, etc.)'); ['-I, --include-path <dir>', 'Add a C++ include directory (repeatable)'],
$climate->tab()->out('--build-dir <dir> Specify build directory for generated C++ code (default: <root>/build)'); ['-D, --define <macro>', 'Define a preprocessor macro (repeatable)'],
$climate->tab()->out('--dry Dry run: only generate C++ code, skip compilation and linking'); ['--format', 'Format generated C++ code with clang-format'],
$climate->tab()->out('-I, --include-path <dir> Add an additional C++ include directory (repeatable)'); ['-l, --link-lib <lib>', 'Link against a library (repeatable)'],
$climate->tab()->out('-D, --define <macro> Define a preprocessor macro (repeatable, e.g. -D FOO=bar)'); ['-L, --link-path <dir>', 'Add a library search path (repeatable)'],
$climate->tab()->out('--format Enable clang-format code formatting (disabled by default)'); ]);
$climate->tab()->out('-l, --link-lib <lib> Link against a library (repeatable, e.g. -lcurl)'); $climate->br();
$climate->tab()->out('-L, --link-path <dir> 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(); $climate->br();
} }
@ -411,6 +444,17 @@ class Translator extends Preprocessor
{ {
$this->applyPhpVersionCommandLineArgument(); $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 // Optimization level
if ($this->climate->arguments->defined('optimize')) { if ($this->climate->arguments->defined('optimize')) {
$this->optimizeLevel = $this->climate->arguments->get('optimize'); $this->optimizeLevel = $this->climate->arguments->get('optimize');
@ -540,6 +584,21 @@ class Translator extends Preprocessor
if ($this->hasRepeatableArgvFlag(['-L', '--link-path'])) { if ($this->hasRepeatableArgvFlag(['-L', '--link-path'])) {
$this->linkPaths = $this->parseRepeatableArgv(['-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. */ /** 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 // Embedded binaries populate the CLI script fields in $_SERVER at
// request startup, even when the source does not reference $_SERVER. // 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); $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<int>(' . $call . ');';
$code = '#include <php_' . $this->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} */ /** @return array{declarations: string, registration: string} */
private function genTraitMetadataCode(): array private function genTraitMetadataCode(): array
{ {
@ -1027,7 +1123,7 @@ class Translator extends Preprocessor
// Keep <new> out of the shared PCH dependency set used by every source. // Keep <new> out of the shared PCH dependency set used by every source.
$code .= '#include <new>' . PHP_EOL; $code .= '#include <new>' . PHP_EOL;
if ($this->isBuildModeEmbed()) { if ($this->isBuildModeEmbed() && !$this->isNanoMode()) {
$code .= '#include <typephp_runtime.h>' . PHP_EOL; $code .= '#include <typephp_runtime.h>' . PHP_EOL;
} }
@ -1036,7 +1132,10 @@ class Translator extends Preprocessor
$code .= 'extern "C" void save_ps_args(int, char **) {}' . PHP_EOL; $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 = [ $cliHeaders = [
'#include "php_cli_process_title.h"', '#include "php_cli_process_title.h"',
'#include "php_cli_process_title_arginfo.h"', '#include "php_cli_process_title_arginfo.h"',
@ -1235,7 +1334,10 @@ CODE;
$code .= "// clang-format off\n"; $code .= "// clang-format off\n";
$code .= "static const zend_function_entry ext_functions[] = {\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_set_process_title, arginfo_cli_set_process_title)\n";
$code .= $this->getIndent() . "PHP_FE(cli_get_process_title, arginfo_cli_get_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 .= $this->getIndent() . 'return FAILURE;' . PHP_EOL;
$code .= '}' . PHP_EOL; $code .= '}' . PHP_EOL;
if (!$this->isWasiTarget()) { if (!$this->isWasiTarget() && !$this->isNanoMode()) {
$code .= 'typephp_register_fiber_generator_class();' . PHP_EOL; $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 .= 'for (auto &slot : ' . self::PREFIX . self::PERSISTENT_PROP_MAP . ') {' . PHP_EOL;
$code .= $this->getIndent() . 'php::resetPersistentCache(slot);' . PHP_EOL; $code .= $this->getIndent() . 'php::resetPersistentCache(slot);' . PHP_EOL;
$code .= '}' . PHP_EOL; $code .= '}' . PHP_EOL;
if (!$this->isWasiTarget()) { if (!$this->isWasiTarget() && !$this->isNanoMode()) {
$code .= 'typephp_unregister_fiber_generator_class();' . PHP_EOL; $code .= 'typephp_unregister_fiber_generator_class();' . PHP_EOL;
} }
if ($traitMetadata['registration'] !== '') { if ($traitMetadata['registration'] !== '') {
@ -1503,19 +1605,45 @@ CODE;
// rinit begin // rinit begin
$code .= 'PHP_RINIT_FUNCTION(' . $moduleName . ') {' . PHP_EOL; $code .= 'PHP_RINIT_FUNCTION(' . $moduleName . ') {' . PHP_EOL;
$code .= 'if (UNEXPECTED(php_request_cache != nullptr)) {' . 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 .= $this->getIndent() . 'return FAILURE;' . PHP_EOL;
$code .= '}' . PHP_EOL; $code .= '}' . PHP_EOL;
$code .= 'php_request_cache = new (std::nothrow) php_request_cache_storage{};' . 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 .= '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 .= $this->getIndent() . 'return FAILURE;' . PHP_EOL;
$code .= '}' . PHP_EOL; $code .= '}' . PHP_EOL;
$code .= 'php::request_init();' . 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; $code .= 'module_init();' . PHP_EOL;
if ($this->isBuildModeBin()) { if ($this->isBuildModeBin() && !$this->isNanoMode()) {
$entryFunction = $this->symbols->function(self::ENTRY_FUNCTION); $entryFunction = $this->symbols->function(self::ENTRY_FUNCTION);
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 {
// FunctionDef::sourceFile comes from loadFile()'s realpath(), so the // FunctionDef::sourceFile comes from loadFile()'s realpath(), so the
// CLI script fields always identify main()'s canonical absolute file. // CLI script fields always identify main()'s canonical absolute file.
$entryFile = $entryFunction->sourceFile; $entryFile = $entryFunction->sourceFile;
@ -1539,6 +1667,7 @@ CODE;
$code .= 'php::eval(' . $entryScriptArg . ', ' . $entryFileArg . ');' . PHP_EOL; $code .= 'php::eval(' . $entryScriptArg . ', ' . $entryFileArg . ');' . PHP_EOL;
} }
}
$code .= 'return SUCCESS;' . PHP_EOL; $code .= 'return SUCCESS;' . PHP_EOL;
$code .= '}' . PHP_EOL . PHP_EOL; $code .= '}' . PHP_EOL . PHP_EOL;
@ -1586,7 +1715,7 @@ CODE;
if ($this->isBuildModeExt()) { if ($this->isBuildModeExt()) {
$code .= "ZEND_GET_MODULE({$moduleName});\n"; $code .= "ZEND_GET_MODULE({$moduleName});\n";
$code .= '} // namespace ' . $projectNamespace . PHP_EOL; $code .= '} // namespace ' . $projectNamespace . PHP_EOL;
} elseif ($this->isBuildModeEmbed()) { } elseif ($this->isBuildModeEmbed() && !$this->isNanoMode()) {
$code .= '} // namespace ' . $projectNamespace . PHP_EOL . PHP_EOL; $code .= '} // namespace ' . $projectNamespace . PHP_EOL . PHP_EOL;
$code .= 'TYPEPHP_EMBED_GET_MODULE_FUNCTION(' . $this->targetName . ') {' . PHP_EOL; $code .= 'TYPEPHP_EMBED_GET_MODULE_FUNCTION(' . $this->targetName . ') {' . PHP_EOL;
$code .= $this->getIndent() . 'return &' . $projectNamespace . '::' . $moduleName . '_module_entry;' . PHP_EOL; $code .= $this->getIndent() . 'return &' . $projectNamespace . '::' . $moduleName . '_module_entry;' . PHP_EOL;
@ -1751,12 +1880,16 @@ CODE;
{ {
$isCacheableMiscFile = $this->isPhpxMiscFile($cppFile) $isCacheableMiscFile = $this->isPhpxMiscFile($cppFile)
&& !$this->isProjectRuntimeEntryFile($cppFile); && !$this->isProjectRuntimeEntryFile($cppFile);
$isNanoRuntimeSource = isset($this->nanoRuntimeSources[$cppFile]);
if ($isCacheableMiscFile && $this->hasMiscObjectFileCache($cppFile)) { if ($isCacheableMiscFile && $this->hasMiscObjectFileCache($cppFile)) {
if (!$parallel) { if (!$parallel) {
$this->climate->darkGray('[cache] skip: ' . $cppFile); $this->climate->darkGray('[cache] skip: ' . $cppFile);
} }
return; return;
} }
if ($isNanoRuntimeSource && $this->hasNanoObjectFileCache($cppFile, $objectFile)) {
return;
}
if ($isCacheableMiscFile) { if ($isCacheableMiscFile) {
$this->invalidateMiscObjectCache($objectFile); $this->invalidateMiscObjectCache($objectFile);
@ -1780,6 +1913,28 @@ CODE;
if ($isCacheableMiscFile) { if ($isCacheableMiscFile) {
$this->writeMiscObjectCacheMetadata($cppFile, $objectFile); $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 protected function getSourceCompileCommandOptions(string $sourceFile, ?string $language): CompileOptions
@ -1806,8 +1961,10 @@ CODE;
{ {
$job = $this->maxJob; $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. // 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'; $runtimeSource = $this->getPhpxDir() . '/src/misc/typephp_runtime.cc';
// PHPX 2.6.3 keeps the common runtime in typephp_main.cc. Newer // 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 // 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'; $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/php_cli_process_title.c';
$sourceFiles[] = $this->getPhpxDir() . '/src/misc/ps_title.c'; $sourceFiles[] = $this->getPhpxDir() . '/src/misc/ps_title.c';
} }
if (!$this->isNanoMode()) {
$this->preparePhpXPrecompiledHeader(); $this->preparePhpXPrecompiledHeader();
}
// Windows: compile the resource file (icon, version info, etc.) // Windows: compile the resource file (icon, version info, etc.)
$this->compileResourceFile(); $this->compileResourceFile();
@ -1836,6 +1998,47 @@ CODE;
return $this->compileWithPcntl($sourceFiles, $job); return $this->compileWithPcntl($sourceFiles, $job);
} }
/** @param list<string> $generatedSources @return list<string> */
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<string> $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 protected function preparePhpXPrecompiledHeader(): void
{ {
$backend = $this->getCompilerBackend(); $backend = $this->getCompilerBackend();
@ -2064,11 +2267,69 @@ CODE;
$this->error($buildError); $this->error($buildError);
} }
if ($this->isNanoMode()) {
$this->auditNanoArtifact($objectFiles, $targetFile);
}
$this->climate->green('Build successful: ' . $targetFile); $this->climate->green('Build successful: ' . $targetFile);
return $targetFile; return $targetFile;
} }
/** @param list<string> $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<string> $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 protected function getNativeBuilder(): NativeBuilder
{ {
return $this->nativeBuilder ??= new NativeBuilder($this->getCompilerBackend()); return $this->nativeBuilder ??= new NativeBuilder($this->getCompilerBackend());
@ -2338,7 +2599,28 @@ CODE;
public function genIncludeHeaderFiles(): string 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}_func_decl.h",
"php_{$this->targetName}_data_decl.h", "php_{$this->targetName}_data_decl.h",
], $this->localHeaders); ], $this->localHeaders);
@ -3103,6 +3385,12 @@ CODE;
$ast = $this->parser->parse($phpCode); $ast = $this->parser->parse($phpCode);
$traverser = new NodeTraverser(); $traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); $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( $traverser->addVisitor(new VoidCastValidationVisitor(
fn (Node $node, string $message) => $this->fatalError($node, $message), fn (Node $node, string $message) => $this->fatalError($node, $message),
)); ));
@ -4864,7 +5152,7 @@ CODE;
} else { } else {
$isEntryFunction = $this->hasFunction(self::ENTRY_FUNCTION) $isEntryFunction = $this->hasFunction(self::ENTRY_FUNCTION)
&& $functionDef === $this->getFunction(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 // $_SERVER initialization must live inside the main entry function so
// the runtime environment and superglobal context are fully ready // the runtime environment and superglobal context are fully ready
// before it is accessed. // before it is accessed.
@ -5054,6 +5342,7 @@ CODE;
} }
} }
$this->initializeImmutableFunctionContext(); $this->initializeImmutableFunctionContext();
$this->prepareReferenceCaptureDegradations($v->stmts, true);
if ($this->functionDef->generator) { if ($this->functionDef->generator) {
try { try {
@ -5138,11 +5427,15 @@ CODE;
// Native classes may be discovered after an earlier declaration was // Native classes may be discovered after an earlier declaration was
// normalized; the final ABI must use the precise native pointer type, // normalized; the final ABI must use the precise native pointer type,
// not a stale php::Object spelling cached during preprocessing. // 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; $code = $functionDeclCode . ' {' . PHP_EOL;
$this->indentLevel++; $this->indentLevel++;
$preamble = $this->genScopeVarDecl(); $preamble = $this->genDegradedArgumentLocals();
$preamble .= $this->genScopeVarDecl();
$preamble .= $this->genNativeObjectParameterChecks($this->functionDef); $preamble .= $this->genNativeObjectParameterChecks($this->functionDef);
// Runtime union/nullable parameter type checks // Runtime union/nullable parameter type checks
foreach ($this->functionDef->argInfoList as $i => $argInfo) { foreach ($this->functionDef->argInfoList as $i => $argInfo) {

@ -3,6 +3,8 @@ use TypePhp\Translator;
use TypePhp\Build\WasiToolchain; use TypePhp\Build\WasiToolchain;
use TypePhp\Build\WasiProjectConfig; use TypePhp\Build\WasiProjectConfig;
use TypePhp\Build\PhpxLocator; use TypePhp\Build\PhpxLocator;
use TypePhp\Build\NativeSourceProjectBuilder;
use TypePhp\Build\NativeSourceProjectConfig;
use TypePhp\PythonTools\Command as PythonToolsCommand; use TypePhp\PythonTools\Command as PythonToolsCommand;
use TypePhp\Cli\CompletionCommand; use TypePhp\Cli\CompletionCommand;
@ -42,6 +44,11 @@ function main(int $argc, array $argv): void
return; return;
} }
if (shouldCompileNativeSourceProject($argv)) {
compileNativeSourceProject($argv);
return;
}
if (getenv('TYPEPHP_WASM_INTERNAL_COMPILE') !== '1' && shouldCompileWasm($argv)) { if (getenv('TYPEPHP_WASM_INTERNAL_COMPILE') !== '1' && shouldCompileWasm($argv)) {
compileWasmProgram($argv); compileWasmProgram($argv);
return; 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. * 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 * The lower-level build scripts are implementation details and are not part of
@ -123,12 +208,17 @@ function compileWasmProgram(array $argv): void
$input = null; $input = null;
$buildDir = null; $buildDir = null;
$profile = null; $profile = null;
$nano = false;
$arguments = array_slice($argv, 1); $arguments = array_slice($argv, 1);
for ($i = 0, $count = count($arguments); $i < $count; $i++) { for ($i = 0, $count = count($arguments); $i < $count; $i++) {
$argument = $arguments[$i]; $argument = $arguments[$i];
if ($argument === '--wasm') { if ($argument === '--wasm') {
continue; continue;
} }
if ($argument === '--nano') {
$nano = true;
continue;
}
if (str_starts_with($argument, '--wasm=')) { if (str_starts_with($argument, '--wasm=')) {
$value = substr($argument, strlen('--wasm=')); $value = substr($argument, strlen('--wasm='));
if ($value === '') { if ($value === '') {
@ -188,7 +278,8 @@ function compileWasmProgram(array $argv): void
exit(1); 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)) { if (!is_executable($builder)) {
fwrite(STDERR, "TypePHP WASI builder is not executable: {$builder}\n"); fwrite(STDERR, "TypePHP WASI builder is not executable: {$builder}\n");
exit(1); exit(1);
@ -237,6 +328,7 @@ function compileWasmProgram(array $argv): void
$environment['TYPEPHP_WASM_MODE'] = $project->mode; $environment['TYPEPHP_WASM_MODE'] = $project->mode;
$environment['TYPEPHP_WASM_PACKAGE'] = $project->package; $environment['TYPEPHP_WASM_PACKAGE'] = $project->package;
$environment['TYPEPHP_WASM_WORLD'] = $project->world; $environment['TYPEPHP_WASM_WORLD'] = $project->world;
$environment['TYPEPHP_WASM_NANO'] = $nano ? '1' : '0';
$compilerExecutable = realpath($argv[0]); $compilerExecutable = realpath($argv[0]);
if ($compilerExecutable === false || !is_executable($compilerExecutable)) { if ($compilerExecutable === false || !is_executable($compilerExecutable)) {
fwrite(STDERR, "Unable to resolve the current TypePHP compiler executable: {$argv[0]}\n"); fwrite(STDERR, "Unable to resolve the current TypePHP compiler executable: {$argv[0]}\n");

@ -0,0 +1,69 @@
--TEST--
Closure reference captures automatically degrade inferred locals to var storage
--FILE--
<?php
function make_counter(): Closure
{
$count = 0;
return static function () use (&$count): int {
return ++$count;
};
}
function replace_parameter(string $value): Closure
{
return static function () use (&$value): array {
$value = ['parameter'];
return $value;
};
}
final class ParameterCapture
{
public function replace(array $value): Closure
{
return function () use (&$value): string {
$value = 'method';
return $value;
};
}
}
function main(): void
{
$counter = make_counter();
var_dump($counter());
var_dump($counter());
$value = 'before';
$change = static function () use (&$value): void {
$value = ['after'];
};
$change();
var_dump($value);
$object = new stdClass();
$replaceObject = static function () use (&$object): void {
$object = 'replaced';
};
$replaceObject();
var_dump($object);
$replaceParameter = replace_parameter('before');
var_dump($replaceParameter());
var_dump((new ParameterCapture())->replace([])());
}
?>
--EXPECT--
int(1)
int(2)
array(1) {
[0]=>
string(5) "after"
}
string(8) "replaced"
array(1) {
[0]=>
string(9) "parameter"
}
string(6) "method"

@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
fatal_error() {
printf 'Fatal error: %s\n' "$1" >&2
exit 1
}
if [[ $# -ne 4 ]]; then
echo "Usage: $0 <program.php> <output.wasm|-> <phpx-dir> <tpc-executable>" >&2
exit 1
fi
caller_dir=${PWD}
input=$1
typephp_compiler=$4
wasm_mode=${TYPEPHP_WASM_MODE:-command}
if [[ "${wasm_mode}" != command ]]; then
fatal_error "--nano --wasm currently supports command/binary projects only"
fi
if [[ "${input}" != /* ]]; then
input=${caller_dir}/${input}
fi
input=$(realpath "${input}")
stem=$(basename "${input}" .php)
stem=${stem//[^a-zA-Z0-9_-]/_}
build_root=${TYPEPHP_WASM_PROGRAM_BUILD_DIR:-${caller_dir}/build}
mkdir -p "${build_root}"
build_root=$(cd "${build_root}" && pwd)
if [[ $2 != - ]]; then
output=$2
if [[ "${output}" != /* ]]; then
output=${caller_dir}/${output}
fi
else
output=${caller_dir}/${stem}.wasm
fi
mkdir -p "$(dirname "${output}")"
# Keep code generation, parallel scheduling, progress, caching, and linking in
# the normal TypePHP compiler. Nano changes only the runtime sources and link
# inputs; this script only orchestrates WASI component/browser packaging.
TYPEPHP_WASM_INTERNAL_COMPILE=1 "${typephp_compiler}" "${input}" \
--nano \
--target-platform wasm32-wasip2 \
--build-dir "${build_root}" \
-O2 \
-o "${output}"
echo "Built TypePHP/Nano WASI program: ${output}"
if [[ "${TYPEPHP_WASM_BROWSER:-0}" == 1 ]]; then
jco_bin=${TYPEPHP_JCO:-jco}
browser_dir=${TYPEPHP_WASM_BROWSER_DIR:-${output%.wasm}.browser}
mkdir -p "${browser_dir}"
jco_flags=()
if "${jco_bin}" transpile --help 2>&1 | grep -q -- '--bindgen-enable-wasm-exnref'; then
jco_flags+=(--bindgen-enable-wasm-exnref)
fi
if ! "${jco_bin}" transpile --help 2>&1 | grep -q -- '--async-wasi-imports'; then
fatal_error "Jco does not support JSPI-backed asynchronous WASI imports; upgrade Jco"
fi
jco_flags+=(--async-mode jspi --async-wasi-imports --async-wasi-exports)
"${jco_bin}" transpile "${output}" \
-o "${browser_dir}" \
--name program \
--no-nodejs-compat \
--no-namespaced-exports \
--instantiation async \
--base64-cutoff=0 \
"${jco_flags[@]}"
echo "Built TypePHP/Nano WASI browser module: ${browser_dir}"
fi
if [[ "${TYPEPHP_WASM_RUN:-0}" == 1 ]]; then
wasmtime_bin=${TYPEPHP_WASMTIME:-wasmtime}
XDG_CACHE_HOME=${XDG_CACHE_HOME:-/tmp/typephp-wasmtime-cache} \
"${wasmtime_bin}" "${output}"
fi
Loading…
Cancel
Save