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`。全局作用域
> 不允许可执行语句;可执行代码必须位于函数或方法内。
### 无 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` 选择:

@ -254,6 +254,44 @@ string(16) "Linux ..."
> arguments, and must return `void`. Top-level executable statements are not
> allowed; executable code belongs in a function or method.
### VM-free Nano executable
Use `--nano` to compile one PHP source file together with PHP Nano and PHPX
sources. The result does not link `libphp` and contains no Zend opcode
interpreter:
```bash
./bin/tpc.php --nano examples/hello.php
./hello
```
By default, the executable is emitted in the directory where `tpc` was invoked.
Normal and Nano builds share the `build` directory for generated code, objects,
and other intermediate files. Use `-o` to select a different output path.
PHP and Composer remain build-time tools. On Linux, macOS, iOS, and Android,
the generated program uses the statically selected Nano runtime and its
file-only stream layer. Native Nano may use C11, C++17, and POSIX.1-2008, but
socket/DNS/network, remote streams, dynamic PHP loading, and process execution
remain unavailable. WASI is a smaller subset; direct calls to APIs missing from
that target are compile-time errors.
On every platform, `--nano` rejects the VM entry paths `eval`, `include`,
`include_once`, `require`, and `require_once`, as well as anonymous classes.
Windows uses a different build backend even when `--nano` is specified: it keeps
the existing host compile/link pipeline and connects to `php.dll` and `phpx.dll`
through their import libraries. It does not load the `swoole/php-nano` or
`swoole/phpx` source manifests, nor append their C/C++ files to project `sources`.
External-command APIs and backtick syntax are still rejected. Those command
functions are also removed from the Zend function table at request startup, so
indirect variable/callback calls cannot bypass the policy.
Except for runtime sources, include directories, compile definitions, and link
inputs, Nano and normal mode share command-line parsing, TypePHP code generation,
parallel scheduling, the compilation progress bar, output path rules, and the
`main(int $argc, array $argv)` argument contract.
## Compilation Modes
TypePHP supports three build modes, selected with `-m` / `--mode`:

@ -144,7 +144,7 @@ _typephp_tpc()
return
;;
-* )
COMPREPLY=( $(compgen -W '-O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version -f --force -m --mode -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") )
COMPREPLY=( $(compgen -W '--nano -O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version -f --force -m --mode -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") )
return
;;
esac

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

@ -108,16 +108,22 @@ incompatible with or more restrictive than standard PHP.
becomes a C++ `T&`, and an exact `int/string/float/bool/array &$arg` on a
statically resolved TypePHP call also uses `T&` without boxing or allocating a
Zend reference. Rebinding, conditional/loop-local first binding, `unset`,
by-reference Closure capture, returning the local by reference, or storing the
reference in a property/array/global is rejected because the C++ reference may
not escape or change its target.
returning the local by reference, or storing the reference in a
property/array/global is rejected because the C++ reference may not escape or
change its target. Closure `use (&$value)` is an explicit degradation boundary:
before parsing the function body, the compiler marks the local for `php::Var`
storage starting at its first assignment, so an escaping Closure can retain a
normal Zend reference. A strongly typed parameter keeps its original call ABI
and is copied into a same-named local `php::Var` slot at function entry.
- Dynamic calls and Closure calls still require explicit `std::ref()` / `toRef()`.
TypePHP creates a call-scoped Zend reference, validates its type on write-back,
and reports an error if dynamic code retains it beyond the call. Code requiring
unrestricted PHP reference identity should initialize the local with
`std::any()` and use the existing `php::Var`/`php::Ref` path.
- Fixed object, resource/stream, high-precision, Native/typed-object, Box, and
`std`-container locals cannot be referenced. These values already have
- Ordinary object, resource/stream, and high-precision locals also degrade to
`php::Var` when captured by reference. Native-object and `std`-container locals
cannot safely discard their compile-time storage layouts and still cannot be
captured this way. These values already have
handle/reference-like semantics, while rebinding their statically typed local
slot would weaken the type system. Typed object/static properties remain
reference-capable because Zend attaches property type sources; PHP array

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

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

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

@ -1,7 +1,7 @@
<?php
function main(): void
{
$arr = std::any([1, 2]);
$arr = [1, 2];
$copy = function () use ($arr) {
$arr[] = 3;
return $arr;
@ -14,7 +14,7 @@ function main(): void
};
$ref();
$value = std::any('old');
$value = 'old';
$returnCapturedRef = function () use (&$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);
self::assertIsString($code);
self::assertStringContainsString('php::Var arr;', $code);
self::assertStringContainsString('php::Var value;', $code);
self::assertStringContainsString('auto copy = [arr = arr]() mutable -> php::Var {', $code);
self::assertSame(2, substr_count($code, 'php::newClosureWithParameters('));
self::assertSame(2, substr_count($code, 'php::ClosureStrictTypes::Enabled'));
self::assertStringContainsString('auto ref = [&arr]() mutable -> php::Var {', $code);
self::assertStringContainsString(
'auto returnCapturedRef = [&value]() mutable -> php::Var {',
$code,
);
self::assertStringNotContainsString('php::newClosureWithParameters(', $code);
}
public function testClosureRebindingIsRejectedAtCompileTime(): void

@ -459,6 +459,18 @@ PHP);
$this->assertStringContainsString('App\\\\Worker\\\\Config\\\\PATH', $code);
}
public function testInlineStringArrayKeyUsesZendStringPointer(): void
{
$this->setPropertyValue('noLiteralStrings', true);
$code = $this->invokeMethod(
'parseArrayKey',
new \PhpParser\Node\Scalar\String_('runtime'),
);
$this->assertSame('php::Str{ZEND_STRL("runtime")}.str()', $code);
}
public function testDynamicallyDefinedConstantsAreNotInternalConstants(): void
{
$name = 'AOT_USER_DEFINE_' . str_replace('.', '_', uniqid('', true));

@ -48,6 +48,35 @@ PHP,
$extension,
);
self::assertStringNotContainsString('php::eval("\\n', $extension);
self::assertStringNotContainsString('zend_disable_functions(', $extension);
}
public function testNanoEntrypointForwardsArgcAndArgvWithTheSharedContract(): void
{
$source = $this->projectDir . '/main.php';
file_put_contents($source, <<<'PHP'
<?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

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

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

@ -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');
}
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);
}
public function testExplicitAnySupportsReferenceCapture(): void
public function testExplicitAnyUsesDirectReferenceCaptureInNonEscapingClosure(): void
{
$code = $this->compileSource(
$this->createCompiler(),
TYPEPHP_ROOT_PATH . '/phpunit/code/any-reference-capture.php',
);
self::assertStringContainsString('php::Var changed', $code);
self::assertStringContainsString('changed.toReference()', $code);
self::assertStringContainsString(
'auto set = [&changed]() mutable -> php::Var {',
$code,
);
self::assertStringNotContainsString('php::newClosureWithParameters(', $code);
}
public function testDestructuringKeepsNativeTargetsAndConvertsDynamicItems(): void

@ -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'];
}
if (!empty($config['section_gc'])) {
$cmd .= ' -ffunction-sections -fdata-sections';
}
if (!empty($config['wasi_exceptions'])) {
$cmd .= ' -fwasm-exceptions'
. ' -mllvm -wasm-enable-sjlj'
. ' -mllvm -wasm-use-legacy-eh=false';
}
if (!empty($config['target_platform'])) {
$cmd .= ' --target=' . $config['target_platform'];
}
@ -209,6 +219,10 @@ abstract class GccLikeBackend extends CompilerBackend
$cmd .= ' ' . escapeshellarg($sourceFile);
$cmd .= ' -o ' . escapeshellarg($outputFile);
if (!empty($options['c_std'])) {
$cmd .= ' -std=' . $options['c_std'];
}
if (!empty($options['include_paths'])) {
$cmd .= ' ' . $this->formatIncludePaths($options['include_paths']);
}

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

@ -33,6 +33,18 @@ trait NativeCommandOptionsTrait
$userDefines[] = 'TYPEPHP_NO_MAIN=1';
$userDefines[] = $this->getLibraryExportsMacroName() . '=1';
}
if ($this->isNanoMode()) {
array_push(
$userDefines,
'TYPEPHP_NATIVE=1',
'PHP_NANO=1',
'PHPX_NANO=1',
'_POSIX_C_SOURCE=200809L',
);
if ($this->isWasiTarget()) {
$userDefines[] = 'ZEND_MM_ERROR=0';
}
}
$values = [
'include_paths' => $includePaths,
@ -47,6 +59,8 @@ trait NativeCommandOptionsTrait
'prof_output' => $this->targetName . '.prof',
'user_defines' => $userDefines,
'lto' => $this->enableLto,
'section_gc' => $this->isNanoMode(),
'wasi_exceptions' => $this->isNanoMode() && $this->isWasiTarget(),
];
if ($this->debug && $this->isWindows()) {
@ -90,7 +104,8 @@ trait NativeCommandOptionsTrait
protected function getCCompileCommandOptions(): CompileOptions
{
$options = $this->getCommonCompileCommandOptions();
return $options->with('suppressed_warnings', ['4244', '4146']);
$options = $options->with('suppressed_warnings', ['4244', '4146']);
return $this->isNanoMode() ? $options->with('c_std', 'c11') : $options;
}
protected function getPrecompiledHeaderCompileCommandOptions(): CompileOptions
@ -145,6 +160,15 @@ trait NativeCommandOptionsTrait
$targetPlatform = $this->getFullStaticTargetTriple();
$ldflags = trim('-static -B ' . escapeshellarg($this->getFullStaticMuslDir()) . ' ' . $ldflags);
}
if ($this->isNanoMode()) {
$gcSections = $this->isMacos() ? '-Wl,-dead_strip' : '-Wl,--gc-sections';
$ldflags = trim($gcSections . ' ' . $ldflags);
if ($this->isWasiTarget()) {
$ldflags = trim(
'-fwasm-exceptions -lsetjmp -lunwind ' . $ldflags,
);
}
}
$options = [
'library_paths' => $libraryPaths,
@ -158,7 +182,9 @@ trait NativeCommandOptionsTrait
'target_platform' => $targetPlatform,
];
$rpaths = $this->getPlatform()->getDefaultRpaths($this->getPhpxDir(), $this->getPhpDir());
$rpaths = $this->isNanoMode()
? []
: $this->getPlatform()->getDefaultRpaths($this->getPhpxDir(), $this->getPhpDir());
if (!empty($rpaths)) {
$options['rpath'] = $rpaths;
}

@ -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
{
/**
* 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
{
$this->sourceDirs = array_merge($this->sourceDirs, $files);
@ -79,6 +149,10 @@ trait SourcePipelineTrait
{
$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) {
try {
$phpDir = (new LibPhpInstaller())->ensure($this->getPhpDir()) ?? $this->getPhpDir();
@ -109,6 +183,7 @@ trait SourcePipelineTrait
if (defined('TYPEPHP_PHP_SCRIPT_ENTRY') && !($this->getPlatform() instanceof Wasi)) {
$this->validatePhpxLibrary();
}
}
$this->validateCompilerToolchain();
@ -117,7 +192,8 @@ trait SourcePipelineTrait
// All Windows build modes depend on the PHPX import library and runtime.
// Other platforms only run the existing checks in embedded build mode.
if ($this->isBuildModeEmbed() || $this->getPlatform() instanceof Windows) {
if (!$this->isNanoMode()
&& ($this->isBuildModeEmbed() || $this->getPlatform() instanceof Windows)) {
foreach ($this->getPlatform()->getBuildLibraryWarnings(
$this->getPhpDir(),
$this->getPhpxDir(),
@ -306,8 +382,12 @@ trait SourcePipelineTrait
// runtime data declarations
$this->genFunctionDeclarations($this->getIncludeDir() . "/php_{$this->targetName}_func_decl.h");
$this->genDataDeclarations($this->getIncludeDir() . "/php_{$this->targetName}_data_decl.h");
// Generate the extension module source file
// Nano keeps the ordinary statically registered Zend class/module
// metadata, then adds a direct native process entry beside it.
$sourceFiles[] = $this->genExtension();
if ($this->isNanoMode()) {
$sourceFiles[] = $this->genNanoEntrypoint();
}
return $sourceFiles;
} finally {

@ -200,9 +200,9 @@ class CompilerBase implements PropertyAccessContext
];
/**
* APIs which cannot have the same semantics in Wasmtime and a browser.
* Keep this list at the language boundary so a WASI build never degrades
* into a link error or a browser-only implementation.
* APIs omitted by the smaller WASI capability profile. Keep the rejection
* at the language boundary so a direct call never degrades into a link
* error or an internal ENOTSUP compatibility stub.
*/
private const array WASI_UNSUPPORTED_FUNCTIONS = [
'exec',
@ -215,17 +215,31 @@ class CompilerBase implements PropertyAccessContext
'proc_terminate',
'shell_exec',
'system',
'flock',
'umask',
'chown',
'chgrp',
'lchown',
'lchgrp',
'fsockopen',
'pfsockopen',
'stream_socket_accept',
'stream_socket_client',
'stream_socket_enable_crypto',
'stream_socket_get_crypto_status',
'stream_socket_get_name',
'stream_socket_pair',
'stream_socket_recvfrom',
'stream_socket_sendto',
'stream_socket_server',
'stream_socket_shutdown',
'gethostbyaddr',
'gethostbyname',
'gethostbynamel',
'gethostname',
'dns_check_record',
'dns_get_mx',
'dns_get_record',
];
private const array WASI_UNSUPPORTED_FUNCTION_PREFIXES = [
@ -233,6 +247,89 @@ class CompilerBase implements PropertyAccessContext
'posix_',
'socket_',
];
/** APIs excluded by php-nano's C/C++/POSIX capability model. */
private const array NANO_UNSUPPORTED_FUNCTIONS = [
'dl',
'exec',
'passthru',
'popen',
'proc_close',
'proc_get_status',
'proc_nice',
'proc_open',
'proc_terminate',
'shell_exec',
'system',
'getenv',
'putenv',
'set_time_limit',
'parse_str',
'fsockopen',
'pfsockopen',
'stream_select',
'stream_get_transports',
'stream_get_filters',
'stream_filter_register',
'stream_bucket_make_writeable',
'stream_bucket_prepend',
'stream_bucket_append',
'stream_bucket_new',
'stream_socket_accept',
'stream_socket_client',
'stream_socket_enable_crypto',
'stream_socket_get_crypto_status',
'stream_socket_get_name',
'stream_socket_pair',
'stream_socket_recvfrom',
'stream_socket_sendto',
'stream_socket_server',
'stream_socket_shutdown',
'gethostbyaddr',
'gethostbyname',
'gethostbynamel',
'gethostname',
'dns_check_record',
'dns_get_mx',
'dns_get_record',
'header',
'header_remove',
'headers_list',
'headers_sent',
'http_response_code',
'mail',
'openlog',
'closelog',
'syslog',
];
/** Calls forbidden by Nano policy even when the full Windows PHP DLL is used. */
private const array NANO_POLICY_UNSUPPORTED_FUNCTIONS = [
'exec',
'passthru',
'pcntl_exec',
'popen',
'proc_close',
'proc_get_status',
'proc_nice',
'proc_open',
'proc_terminate',
'shell_exec',
'system',
];
private const array NANO_POLICY_UNSUPPORTED_FUNCTION_PREFIXES = [
'proc_',
];
private const array NANO_UNSUPPORTED_FUNCTION_PREFIXES = [
'pcntl_',
'posix_',
'socket_',
'curl_',
'ftp_',
'opcache_',
];
public const int DECL_TYPE_OF_RETURN = 1;
public const int DECL_TYPE_OF_PROPERTY = 2;
public const int DECL_TYPE_OF_CONST = 3;
@ -422,6 +519,16 @@ class CompilerBase implements PropertyAccessContext
protected array $userDefines = []; // --define / -D: user-provided preprocessor macros
protected bool $enableLto = false; // --lto: enable Link Time Optimization (-flto)
protected bool $fullStatic = false; // --full-static: link against the bundled fully-static SDK
/** Generate a VM-less program entry for the Composer php-nano runtime. */
protected bool $nanoMode = false;
/** Enforce the VM-free and external-command restrictions of `--nano`. */
protected bool $nanoPolicyMode = false;
/** @var list<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 $dir;
@ -697,6 +804,16 @@ class CompilerBase implements PropertyAccessContext
return $target === 'wasm32-unknown-wasip2' || $target === 'wasm32-wasip2';
}
public function isNanoMode(): bool
{
return $this->nanoMode;
}
public function isNanoPolicyMode(): bool
{
return $this->nanoMode || $this->nanoPolicyMode;
}
protected function assertWasiFunctionSupported(NodeAbstract $expr, string $name): void
{
if (!$this->isWasiTarget()) {
@ -714,6 +831,43 @@ class CompilerBase implements PropertyAccessContext
}
}
protected function assertNanoFunctionSupported(NodeAbstract $expr, string $name): void
{
if (!$this->isNanoPolicyMode()) {
return;
}
$name = strtolower(ltrim($name, '\\'));
if (in_array($name, self::NANO_POLICY_UNSUPPORTED_FUNCTIONS, true)) {
$this->fatalError($expr, "Function `{$name}` is not supported in nano mode");
}
foreach (self::NANO_POLICY_UNSUPPORTED_FUNCTION_PREFIXES as $prefix) {
if (str_starts_with($name, $prefix)) {
$this->fatalError($expr, "Function `{$name}` is not supported in nano mode");
}
}
// Windows Nano uses the complete PHP/PHPX DLL set. Only the common
// policy above applies; php-nano's smaller host surface is Unix/WASI.
if (!$this->isNanoMode()) {
return;
}
if (in_array($name, self::NANO_UNSUPPORTED_FUNCTIONS, true)) {
$this->fatalError($expr, "Function `{$name}` is not supported in nano mode");
}
foreach (self::NANO_UNSUPPORTED_FUNCTION_PREFIXES as $prefix) {
if (str_starts_with($name, $prefix)) {
$this->fatalError($expr, "Function `{$name}` is not supported in nano mode");
}
}
}
protected function getNanoPolicyDisabledFunctionList(): string
{
return implode(',', self::NANO_POLICY_UNSUPPORTED_FUNCTIONS);
}
public function isBuildModeBin(): bool
{
return $this->buildMode === self::BUILD_MODE_BIN;
@ -1600,9 +1754,11 @@ class CompilerBase implements PropertyAccessContext
{
$this->assertNotNativeObjectArrayKey($expr);
$key = $this->parseIdentifier($expr);
if (str_starts_with($key, self::LITERAL_STRING_GETTER . '(')) {
if ($expr instanceof Node\Scalar\String_) {
// Array initializers and setters use zend_string* keys, while item()
// uses php::String to avoid an ambiguous conversion to Variant.
// uses php::String to avoid an ambiguous conversion to Variant. This
// must also cover inline literals used by native/Nano builds, where
// no persistent literal-string table is generated.
return $keepStringObject ? $key : "{$key}.str()";
}
if ($this->isZeroLiteral($expr)) {
@ -4925,6 +5081,9 @@ class CompilerBase implements PropertyAccessContext
protected function parseShellExec(Expr\ShellExec $expr): string
{
if ($this->isNanoPolicyMode()) {
$this->fatalError($expr, 'Backtick shell execution is not supported in nano mode');
}
if ($this->isWasiTarget()) {
$this->fatalError($expr, 'Backtick shell execution is not supported by the WASI target');
}

@ -14,12 +14,82 @@ use TypePhp\Entity\ClassDef;
use TypePhp\Entity\FunctionDef;
use TypePhp\Entity\InterfaceDef;
use TypePhp\Exception\Skip;
use TypePhp\Analysis\ReferenceCaptureAnalyzer;
use TypePhp\Type;
trait CompilationStateTrait
{
protected function addLocalVar(string $name, string $type): void
{
$this->context->localVars[$name] = $type;
$this->context->localVars[$name] = $this->context->varTypeDegradations[$name] ?? $type;
}
/**
* Build the function-local degradation table before parsing any statement.
* A local captured through use (&$var) must own a Zend-compatible php::Var
* slot from its first assignment.
*
* @param NodeAbstract|list<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
@ -67,6 +137,7 @@ trait CompilationStateTrait
if ($this->hasVar($name)) {
$this->fatalError($var, 'Duplicate variable `$' . $var->name . '`');
}
$type = $this->context->varTypeDegradations[$name] ?? $type;
$this->context->staticVars[$name] = $type;
// A static variable is actually a reference to a global variable.
$globalVar = $this->escapeStaticVar($name);

@ -68,6 +68,8 @@ class FunctionContext
*/
public array $stdContainers = [];
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. */
public array $explicitNativeTypeVars = [];
/** @var array<string, string> C++ initializers folded into function-scope local declarations. */
@ -145,6 +147,7 @@ class FunctionContext
public function __construct()
{
$this->localVars = [];
$this->varTypeDegradations = [];
$this->explicitNativeTypeVars = [];
$this->localVarInitializers = [];
$this->localClosureCandidates = [];

@ -143,6 +143,9 @@ trait ClosureGenerator
$this->inGeneratorBody = false;
$this->indentLevel = $entryIndent + 1;
$body = $expr instanceof Expr\ArrowFunction ? $expr->expr : $expr->stmts;
$this->prepareReferenceCaptureDegradations($body);
$returnType = $expr->returnType;
$returnTypeName = $returnType instanceof Node\Identifier
? strtolower($returnType->name)
@ -236,7 +239,12 @@ trait ClosureGenerator
$rawType = $this->getRawVarType($name);
$valueType = Type::getReferencedType($rawType);
if ($useItem->byRef) {
$captureType = Type::getReferenceType($valueType);
// Reference-captured locals are pre-degraded to php::Var. A
// native, non-escaping lambda can bind that slot directly;
// it does not need to manufacture a Zend reference wrapper.
$captureType = $valueType === Type::VAR
? Type::VAR
: Type::getReferenceType($valueType);
if ($captureType === null) {
return null;
}
@ -450,6 +458,8 @@ trait ClosureGenerator
$this->context = new FunctionContext();
$this->context->inClosure = true;
$body = $expr instanceof Expr\ArrowFunction ? $expr->expr : $expr->stmts;
$this->prepareReferenceCaptureDegradations($body);
if (!$isGenerator
&& ($expr->returnType instanceof NullableType
|| $expr->returnType instanceof UnionType
@ -656,6 +666,9 @@ trait ClosureGenerator
$this->inGeneratorBody = true;
$this->indentLevel++;
$body = $expr instanceof Expr\ArrowFunction ? $expr->expr : $expr->stmts;
$this->prepareReferenceCaptureDegradations($body);
try {
foreach ($capturedNames as $i => $capturedName) {
$code .= $this->getIndent() . Type::VAR . ' ' . $capturedName . ' = vars_.get(' . $i . ');' . PHP_EOL;

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

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

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

@ -404,6 +404,15 @@ trait AssignOpTrait
}
if ($this->isNativeObjectClass($rightClass)) {
if ($this->isVarExpr($left)) {
$degradedName = $this->parseVariable($left);
if (isset($this->context->varTypeDegradations[$degradedName])) {
$this->fatalError(
$left,
"Native object variable `\${$degradedName}` cannot be degraded to var for Closure reference capture",
);
}
}
$allowed = false;
if ($this->isVarExpr($left)) {
$leftName = $this->parseVariable($left);
@ -615,6 +624,12 @@ trait AssignOpTrait
if ($this->isStdClassExpr($right->class)) {
$stdMethod = strtolower($right->name->toString());
if (in_array($stdMethod, ['array', 'vector', 'map', 'orderedmap'], true)) {
if (isset($this->context->varTypeDegradations[$var])) {
$this->fatalError(
$left,
"Std container variable `\${$var}` cannot be degraded to var for Closure reference capture",
);
}
if ($this->hasScopeGlobalVar($var) || $this->hasStaticVar($var)) {
$this->assertNativeStdContainerFunctionLocal($right);
}

@ -142,8 +142,13 @@ trait FunctionCallTrait
"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->assertNanoFunctionSupported($expr, $globalName);
if ($this->isInternalFunction($globalName)) {
$this->markInternalFunctionCallbackCall($globalName, $expr->args);
}
if (in_array($globalName, Constants::UNSUPPORTED_FUNCTIONS, true)) {

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

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

@ -430,9 +430,9 @@ trait UniversalMethodCall
}
protected const array TO_CONVERT_FN = [
Type::BIGINT => ['toInt' => 'php::BigInt::toInt', 'toFloat' => 'php::BigInt::toFloat', 'toString' => 'php::BigInt::toString'],
Type::BIGFLOAT => ['toInt' => 'php::BigFloat::toInt', 'toFloat' => 'php::BigFloat::toFloat', 'toString' => 'php::BigFloat::toString'],
Type::DECIMAL => ['toInt' => 'php::Decimal::toInt', 'toFloat' => 'php::Decimal::toFloat', 'toString' => 'php::Decimal::toString'],
Type::BIGINT => ['toInt' => 'php::BigInt::toInt', 'toFloat' => 'php::BigInt::toFloat', 'toString' => 'php::BigInt::toString', 'toBool' => 'php::BigInt::toBool'],
Type::BIGFLOAT => ['toInt' => 'php::BigFloat::toInt', 'toFloat' => 'php::BigFloat::toFloat', 'toString' => 'php::BigFloat::toString', 'toBool' => 'php::BigFloat::toBool'],
Type::DECIMAL => ['toInt' => 'php::Decimal::toInt', 'toFloat' => 'php::Decimal::toFloat', 'toString' => 'php::Decimal::toString', 'toBool' => 'php::Decimal::toBool'],
];
/**
@ -600,6 +600,30 @@ trait UniversalMethodCall
*/
protected function parseUniversalMethodCall(Node\Expr\MethodCall $expr, string $receiver, string $method, array $def, bool $isVar = true): ?string
{
if ($this->isWasiTarget()) {
if ($def['handler'] === 'php_fn') {
$this->assertWasiFunctionSupported($expr, $def['fn']);
} elseif ($def['handler'] === 'cpp_fn'
&& str_starts_with($def['fn'], 'php::fn::')
) {
$this->assertWasiFunctionSupported(
$expr,
substr($def['fn'], strlen('php::fn::')),
);
}
}
if ($this->isNanoPolicyMode()) {
if ($def['handler'] === 'php_fn') {
$this->assertNanoFunctionSupported($expr, $def['fn']);
} elseif ($def['handler'] === 'cpp_fn'
&& str_starts_with($def['fn'], 'php::fn::')
) {
$this->assertNanoFunctionSupported(
$expr,
substr($def['fn'], strlen('php::fn::')),
);
}
}
$this->validateUniversalMethodArgs($expr, $method, $def, $isVar);
return match ($def['handler']) {

@ -23,6 +23,7 @@ use TypePhp\Exception\SyntaxError;
use TypePhp\Transform\PropertyHookLowering;
use TypePhp\Transform\CompileTimeAttribute;
use TypePhp\Transform\NativeClassAttributeLowering;
use TypePhp\Transform\NanoSyntaxValidationVisitor;
use TypePhp\Transform\PrinterLowering;
use TypePhp\Transform\ArrayableLowering;
use TypePhp\Transform\ClassFieldSelection;
@ -324,6 +325,18 @@ class Preprocessor extends CompilerBase
$info = pathinfo($cppFile);
$ext = $this->getPlatform()->getObjectExtension();
// Composer packages are immutable build inputs. Keep their objects in
// the project build directory instead of writing beside vendor sources.
if (isset($this->nanoRuntimeSources[$cppFile])) {
$separator = $this->getPlatform()->getPathSeparator();
$objectDir = $this->buildDir . $separator . 'nano-objects';
if (!is_dir($objectDir)) {
mkdir($objectDir, 0777, true);
}
return $objectDir . $separator . $info['filename'] . '-'
. substr(sha1($cppFile), 0, 12) . $ext;
}
// Keep the same path separator as cppFile
$normalizedFile = str_replace('\\', '/', $cppFile);
$normalizedMiscDir = str_replace('\\', '/', $this->getPhpxDir() . '/src/misc/');
@ -400,6 +413,12 @@ class Preprocessor extends CompilerBase
$traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false]));
if ($this->isNanoPolicyMode()) {
$traverser->addVisitor(new NanoSyntaxValidationVisitor(
fn (Node $node, string $message) => $this->fatalError($node, $message),
$this->isNanoMode(),
));
}
$traverser->addVisitor(new VoidCastValidationVisitor(
fn (Node $node, string $message) => $this->fatalError($node, $message),
));

@ -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\NativeCommandOptionsTrait;
use TypePhp\Build\NativeBuilder;
use TypePhp\Build\NanoBuildBackend;
use TypePhp\Build\NativeDependencyAuditor;
use TypePhp\Build\NanoSourceComposer;
use TypePhp\Build\PrecompiledHeaderManager;
use TypePhp\Build\SourcePipelineTrait;
use TypePhp\Build\WasmInterfaceGenerator;
@ -54,6 +57,7 @@ use TypePhp\Transform\ConstructorLowering;
use TypePhp\Transform\ConstantExpressionValidationVisitor;
use TypePhp\Transform\PropertyHookLowering;
use TypePhp\Transform\RuntimeAttributeFactoryLowering;
use TypePhp\Transform\NanoSyntaxValidationVisitor;
use TypePhp\Transform\VoidCastValidationVisitor;
use PhpParser\Modifiers;
use PhpParser\Node;
@ -153,6 +157,12 @@ class Translator extends Preprocessor
}
unset($this->internalFunctions[self::ENTRY_FUNCTION]);
$this->internalConstants = $this->loadInternalConstants();
// Apply this before --help/--version so informational output also
// honours an explicitly requested plain-text mode.
if ($this->climate->arguments->defined('no-color')) {
$this->climate->forceAnsiOff();
}
if ($this->climate->arguments->defined('help')) {
$this->showUsage();
exit(0);
@ -162,11 +172,6 @@ class Translator extends Preprocessor
exit(0);
}
// Handle --no-color early so all subsequent output is colorless.
if ($this->climate->arguments->defined('no-color')) {
$this->climate->forceAnsiOff();
}
// Detect the OS, the compiler, and (on Windows) the PHP lib files.
$this->detectPlatform();
@ -350,12 +355,34 @@ class Translator extends Preprocessor
global $argv;
$cmd = $argv[0];
$printEntries = static function (array $entries) use ($climate): void {
$width = max(array_map(
static fn (array $entry): int => strlen($entry[0]),
$entries,
));
foreach ($entries as [$syntax, $description]) {
$climate->tab()->out(str_pad($syntax, $width + 2) . $description);
}
};
$climate->bold('USAGE:');
$climate->tab()->out($cmd . ' <file/dir/config.yml> [options]');
$climate->tab()->out($cmd . ' <input> [compilation options] [-- <program arguments...>]');
$climate->tab()->out($cmd . ' <subcommand> [arguments]');
$climate->br();
$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->bold('EXAMPLES:');
@ -364,42 +391,48 @@ class Translator extends Preprocessor
$climate->tab()->out($cmd . ' project/config.yml -O2');
$climate->tab()->out($cmd . ' my-ext/ -O2 -o myapp -m ext');
$climate->tab()->out($cmd . ' app.php -r -O2 -- --flag1 value1');
$climate->tab()->out($cmd . ' --gen-python-helper builtins');
$climate->br();
$climate->bold('OPTIONS:');
$climate->tab()->out('-O <level> Optimization level (0-3, default: 0)');
$climate->tab()->out('--profile Enable performance profiling (adds -lprofiler, forces recompile)');
$climate->tab()->out('-d, --debug Enable debug mode (auto-disable optimizations, add debug symbols)');
$climate->tab()->out('-o, --output <file> Output binary name (default: input basename)');
$climate->tab()->out('-v, --version Show version');
$climate->tab()->out('-h, --help Show this help message');
$climate->tab()->out('-f, --force Force recompile phpx misc files (ignore cache)');
$climate->tab()->out('-m, --mode <mode> Compilation mode: bin (binary), lib (shared library), or ext (PHP extension); default: bin');
$climate->tab()->out('-r, --run Run the compiled binary after build');
$climate->tab()->out('-j, --job <num> Number of parallel compilation jobs (default: 4)');
$climate->tab()->out('--cxx-std <ver> C++ standard version (c++17, c++20, etc., default: c++17)');
$climate->tab()->out('--compiler <cmd> C++ compiler command to use (e.g. --compiler=/usr/bin/clang)');
$climate->tab()->out('--march <arch> Target CPU instruction set (e.g. native, x86-64-v3, armv8-a)');
$climate->tab()->out('--target-platform <triple> Cross-compilation target triple (e.g. aarch64-linux-gnu)');
$climate->tab()->out('--wasm[=profile] Build WASI component (default) or browser output');
$climate->tab()->out('--gen-python-helper <module> [--output-dir <dir>] Generate a Python namespace IDE helper');
$climate->tab()->out('--convert-python-to-php <file.py> Convert Python source to TypePHP source');
$climate->tab()->out('--generate-completion=bash Generate Bash completion script');
$climate->tab()->out('--lto Enable Link Time Optimization (-flto)');
$climate->tab()->out('--no-literal-strings Disable literal strings optimization');
$climate->tab()->out('--php-version <ver> PHP language version to accept (8.4-8.5, default: 8.5)');
$climate->tab()->out('--no-progress Disable progress bar, output per-file compilation progress line by line');
$climate->tab()->out('--no-console Hide console window (Windows only, GUI application)');
$climate->tab()->out('--no-color Disable ANSI color output');
$climate->tab()->out('--sanitize <type> Enable sanitizers (address, undefined, etc.)');
$climate->tab()->out('--build-dir <dir> Specify build directory for generated C++ code (default: <root>/build)');
$climate->tab()->out('--dry Dry run: only generate C++ code, skip compilation and linking');
$climate->tab()->out('-I, --include-path <dir> Add an additional C++ include directory (repeatable)');
$climate->tab()->out('-D, --define <macro> Define a preprocessor macro (repeatable, e.g. -D FOO=bar)');
$climate->tab()->out('--format Enable clang-format code formatting (disabled by default)');
$climate->tab()->out('-l, --link-lib <lib> Link against a library (repeatable, e.g. -lcurl)');
$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('COMPILATION OPTIONS:');
$printEntries([
['-O, --optimize <level>', 'Optimization level (0-3, default: 0)'],
['--profile', 'Enable performance profiling (adds -lprofiler, forces recompile)'],
['-d, --debug', 'Enable debug mode (disables optimizations and adds debug symbols)'],
['-o, --output <file>', 'Output name or path (default: input basename)'],
['-f, --force', 'Force recompilation and ignore the object cache'],
['-m, --mode <mode>', 'Build mode: bin, lib, or ext (default: bin)'],
['-r, --run', 'Run the compiled binary after a successful build'],
['-j, --job <num>', 'Number of parallel compilation jobs (default: 4)'],
['--cxx-std <ver>', 'C++ standard version (default: c++17)'],
['--compiler <cmd>', 'C++ compiler command (for example /usr/bin/clang)'],
['--march <arch>', 'Target CPU instruction set (for example native or armv8-a)'],
['--target-platform <triple>', 'Cross-compilation target triple'],
['--wasm[=browser|component]', 'Build WASI component (default) or browser output'],
['--nano', 'Use the Nano policy and php-nano runtime outside Windows'],
['--full-static', 'Link fully statically against the bundled SDK'],
['--lto', 'Enable Link Time Optimization (-flto)'],
['--no-literal-strings', 'Disable literal string optimization'],
['--php-version <ver>', 'Accepted PHP language version (8.4-8.5, default: 8.5)'],
['--no-progress', 'Print one compilation line per file instead of a progress bar'],
['--no-console', 'Hide the console window (Windows GUI applications only)'],
['--sanitize <type>', 'Enable a sanitizer such as address or undefined'],
['--build-dir <dir>', 'Directory for generated C++ and build files'],
['--dry', 'Generate C++ code without compiling or linking'],
['-I, --include-path <dir>', 'Add a C++ include directory (repeatable)'],
['-D, --define <macro>', 'Define a preprocessor macro (repeatable)'],
['--format', 'Format generated C++ code with clang-format'],
['-l, --link-lib <lib>', 'Link against a library (repeatable)'],
['-L, --link-path <dir>', 'Add a library search path (repeatable)'],
]);
$climate->br();
$climate->bold('GENERAL OPTIONS:');
$printEntries([
['-h, --help', 'Show this help message'],
['-v, --version', 'Show the compiler version'],
['--no-color', 'Disable ANSI color output'],
]);
$climate->br();
}
@ -411,6 +444,17 @@ class Translator extends Preprocessor
{
$this->applyPhpVersionCommandLineArgument();
// The Nano syntax policy is platform-independent. On non-Windows hosts
// only the runtime source and link inputs change; argument parsing,
// translation, compilation scheduling and diagnostics remain shared.
if ($this->climate->arguments->defined('nano')) {
$this->nanoPolicyMode = true;
if (NanoBuildBackend::composesRuntimeSources(PHP_OS_FAMILY)) {
$this->nanoMode = true;
$this->noLiteralStrings = true;
}
}
// Optimization level
if ($this->climate->arguments->defined('optimize')) {
$this->optimizeLevel = $this->climate->arguments->get('optimize');
@ -540,6 +584,21 @@ class Translator extends Preprocessor
if ($this->hasRepeatableArgvFlag(['-L', '--link-path'])) {
$this->linkPaths = $this->parseRepeatableArgv(['-L', '--link-path']);
}
if ($this->isNanoMode()) {
if (!$this->isBuildModeBin()) {
$this->error('--nano source composition only supports binary mode (-m bin)');
}
if ($this->cxxStd !== 'c++17') {
$this->error('--nano requires the C++17 language standard');
}
if ($this->fullStatic) {
$this->error('--nano already composes its runtime sources; --full-static is not applicable');
}
if ($this->linkLibs !== [] || $this->linkPaths !== []) {
$this->error('--nano does not permit external link libraries or library search paths');
}
}
}
/** Apply this option early because YAML source conditions depend on it. */
@ -884,7 +943,9 @@ class Translator extends Preprocessor
// Embedded binaries populate the CLI script fields in $_SERVER at
// request startup, even when the source does not reference $_SERVER.
if ($this->isBuildModeBin() && !$this->hasGlobalVar('_SERVER')) {
if (!$this->isNanoMode()
&& $this->isBuildModeBin()
&& !$this->hasGlobalVar('_SERVER')) {
$this->addGlobalVar('_SERVER', Type::ARRAY);
}
@ -958,6 +1019,41 @@ class Translator extends Preprocessor
}
}
/** Generate the VM-less executable entry used by the Composer php-nano runtime. */
public function genNanoEntrypoint(): string
{
if (!$this->isNanoMode()) {
throw new \LogicException('Nano entry generation requires Nano mode');
}
if (!$this->hasFunction(self::ENTRY_FUNCTION)) {
throw new \RuntimeException('A Nano executable must define main()');
}
$entry = $this->getFunction(self::ENTRY_FUNCTION);
if (count($entry->argInfoList) !== 0 && count($entry->argInfoList) !== 2) {
throw new \RuntimeException(
'Nano main() accepts either no parameters or (int $argc, array $argv)'
);
}
if (!in_array($entry->returnType, [Type::INT, Type::VOID], true)) {
throw new \RuntimeException('Nano main() must return int or void');
}
$file = $this->getBuildDir() . '/nano-entry-' . $this->targetName . '.cc';
$call = count($entry->argInfoList) === 2
? 'php_main(php::global("argc").toInt(), php::global("argv").toArray())'
: 'php_main()';
$entryCall = $entry->returnType === Type::VOID
? $call . ';' . PHP_EOL . ' return 0;'
: 'return static_cast<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} */
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.
$code .= '#include <new>' . PHP_EOL;
if ($this->isBuildModeEmbed()) {
if ($this->isBuildModeEmbed() && !$this->isNanoMode()) {
$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;
}
if ($this->isBuildModeBin() && !$this->isWasiTarget() && !$this->isIosTarget()) {
if (!$this->isNanoMode()
&& $this->isBuildModeBin()
&& !$this->isWasiTarget()
&& !$this->isIosTarget()) {
$cliHeaders = [
'#include "php_cli_process_title.h"',
'#include "php_cli_process_title_arginfo.h"',
@ -1235,7 +1334,10 @@ CODE;
$code .= "// clang-format off\n";
$code .= "static const zend_function_entry ext_functions[] = {\n";
if ($this->isBuildModeBin() && !$this->isWasiTarget() && !$this->isIosTarget()) {
if (!$this->isNanoMode()
&& $this->isBuildModeBin()
&& !$this->isWasiTarget()
&& !$this->isIosTarget()) {
$code .= $this->getIndent() . "PHP_FE(cli_set_process_title, arginfo_cli_set_process_title)\n";
$code .= $this->getIndent() . "PHP_FE(cli_get_process_title, arginfo_cli_get_process_title)\n";
}
@ -1294,7 +1396,7 @@ CODE;
}
$code .= $this->getIndent() . 'return FAILURE;' . PHP_EOL;
$code .= '}' . PHP_EOL;
if (!$this->isWasiTarget()) {
if (!$this->isWasiTarget() && !$this->isNanoMode()) {
$code .= 'typephp_register_fiber_generator_class();' . PHP_EOL;
}
@ -1341,7 +1443,7 @@ CODE;
$code .= 'for (auto &slot : ' . self::PREFIX . self::PERSISTENT_PROP_MAP . ') {' . PHP_EOL;
$code .= $this->getIndent() . 'php::resetPersistentCache(slot);' . PHP_EOL;
$code .= '}' . PHP_EOL;
if (!$this->isWasiTarget()) {
if (!$this->isWasiTarget() && !$this->isNanoMode()) {
$code .= 'typephp_unregister_fiber_generator_class();' . PHP_EOL;
}
if ($traitMetadata['registration'] !== '') {
@ -1503,19 +1605,45 @@ CODE;
// rinit begin
$code .= 'PHP_RINIT_FUNCTION(' . $moduleName . ') {' . PHP_EOL;
$code .= 'if (UNEXPECTED(php_request_cache != nullptr)) {' . PHP_EOL;
$code .= $this->getIndent() . 'php_error_docref(nullptr, E_WARNING, "TypePHP request cache is already initialized");' . PHP_EOL;
$code .= $this->getIndent() . ($this->nanoMode
? 'zend_error(E_WARNING, "TypePHP request cache is already initialized");'
: 'php_error_docref(nullptr, E_WARNING, "TypePHP request cache is already initialized");') . PHP_EOL;
$code .= $this->getIndent() . 'return FAILURE;' . PHP_EOL;
$code .= '}' . PHP_EOL;
$code .= 'php_request_cache = new (std::nothrow) php_request_cache_storage{};' . PHP_EOL;
$code .= 'if (UNEXPECTED(php_request_cache == nullptr)) {' . PHP_EOL;
$code .= $this->getIndent() . 'php_error_docref(nullptr, E_WARNING, "Unable to allocate TypePHP request cache");' . PHP_EOL;
$code .= $this->getIndent() . ($this->nanoMode
? 'zend_error(E_WARNING, "Unable to allocate TypePHP request cache");'
: 'php_error_docref(nullptr, E_WARNING, "Unable to allocate TypePHP request cache");') . PHP_EOL;
$code .= $this->getIndent() . 'return FAILURE;' . PHP_EOL;
$code .= '}' . PHP_EOL;
$code .= 'php::request_init();' . PHP_EOL;
if ($this->isNanoPolicyMode() && !$this->isNanoMode()) {
// The full Windows runtime still contains standard/process modules.
// Remove command functions from Zend's table after every module has
// started so variable functions and call_user_func cannot bypass
// the compile-time named-call check.
$code .= 'zend_disable_functions('
. $this->genCharPtr($this->getNanoPolicyDisabledFunctionList(), true)
. ');' . PHP_EOL;
}
$code .= 'module_init();' . PHP_EOL;
if ($this->isBuildModeBin()) {
if ($this->isBuildModeBin() && !$this->isNanoMode()) {
$entryFunction = $this->symbols->function(self::ENTRY_FUNCTION);
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
// CLI script fields always identify main()'s canonical absolute file.
$entryFile = $entryFunction->sourceFile;
@ -1539,6 +1667,7 @@ CODE;
$code .= 'php::eval(' . $entryScriptArg . ', ' . $entryFileArg . ');' . PHP_EOL;
}
}
$code .= 'return SUCCESS;' . PHP_EOL;
$code .= '}' . PHP_EOL . PHP_EOL;
@ -1586,7 +1715,7 @@ CODE;
if ($this->isBuildModeExt()) {
$code .= "ZEND_GET_MODULE({$moduleName});\n";
$code .= '} // namespace ' . $projectNamespace . PHP_EOL;
} elseif ($this->isBuildModeEmbed()) {
} elseif ($this->isBuildModeEmbed() && !$this->isNanoMode()) {
$code .= '} // namespace ' . $projectNamespace . PHP_EOL . PHP_EOL;
$code .= 'TYPEPHP_EMBED_GET_MODULE_FUNCTION(' . $this->targetName . ') {' . PHP_EOL;
$code .= $this->getIndent() . 'return &' . $projectNamespace . '::' . $moduleName . '_module_entry;' . PHP_EOL;
@ -1751,12 +1880,16 @@ CODE;
{
$isCacheableMiscFile = $this->isPhpxMiscFile($cppFile)
&& !$this->isProjectRuntimeEntryFile($cppFile);
$isNanoRuntimeSource = isset($this->nanoRuntimeSources[$cppFile]);
if ($isCacheableMiscFile && $this->hasMiscObjectFileCache($cppFile)) {
if (!$parallel) {
$this->climate->darkGray('[cache] skip: ' . $cppFile);
}
return;
}
if ($isNanoRuntimeSource && $this->hasNanoObjectFileCache($cppFile, $objectFile)) {
return;
}
if ($isCacheableMiscFile) {
$this->invalidateMiscObjectCache($objectFile);
@ -1780,6 +1913,28 @@ CODE;
if ($isCacheableMiscFile) {
$this->writeMiscObjectCacheMetadata($cppFile, $objectFile);
}
if ($isNanoRuntimeSource) {
$this->writeMiscObjectCacheMetadata($cppFile, $objectFile);
}
}
private function hasNanoObjectFileCache(string $sourceFile, string $objectFile): bool
{
if ($this->climate->arguments->defined('force') || !is_file($objectFile)) {
return false;
}
$metadataFile = $this->getMiscObjectCacheMetadataFile($objectFile);
$cachedKey = is_file($metadataFile) ? file_get_contents($metadataFile) : false;
if ($cachedKey === false
|| trim($cachedKey) !== $this->getMiscObjectCacheKey($sourceFile, $objectFile)) {
return false;
}
$objectMtime = filemtime($objectFile);
$sourceMtime = filemtime($sourceFile);
return $objectMtime !== false
&& $sourceMtime !== false
&& $objectMtime > $sourceMtime
&& $objectMtime >= $this->nanoRuntimeHeaderMtime;
}
protected function getSourceCompileCommandOptions(string $sourceFile, ?string $language): CompileOptions
@ -1806,8 +1961,10 @@ CODE;
{
$job = $this->maxJob;
if ($this->isNanoMode()) {
$sourceFiles = $this->composeNanoRuntimeSources($sourceFiles);
// The embed build needs the main function and the CLI's built-in function definitions.
if ($this->isBuildModeEmbed()) {
} elseif ($this->isBuildModeEmbed()) {
$runtimeSource = $this->getPhpxDir() . '/src/misc/typephp_runtime.cc';
// PHPX 2.6.3 keeps the common runtime in typephp_main.cc. Newer
// PHPX versions split it out so the object can be shared across
@ -1818,12 +1975,17 @@ CODE;
$sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_main.cc';
}
if ($this->isBuildModeBin() && !$this->isWasiTarget() && !$this->isIosTarget()) {
if (!$this->isNanoMode()
&& $this->isBuildModeBin()
&& !$this->isWasiTarget()
&& !$this->isIosTarget()) {
$sourceFiles[] = $this->getPhpxDir() . '/src/misc/php_cli_process_title.c';
$sourceFiles[] = $this->getPhpxDir() . '/src/misc/ps_title.c';
}
if (!$this->isNanoMode()) {
$this->preparePhpXPrecompiledHeader();
}
// Windows: compile the resource file (icon, version info, etc.)
$this->compileResourceFile();
@ -1836,6 +1998,47 @@ CODE;
return $this->compileWithPcntl($sourceFiles, $job);
}
/** @param list<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
{
$backend = $this->getCompilerBackend();
@ -2064,11 +2267,69 @@ CODE;
$this->error($buildError);
}
if ($this->isNanoMode()) {
$this->auditNanoArtifact($objectFiles, $targetFile);
}
$this->climate->green('Build successful: ' . $targetFile);
return $targetFile;
}
/** @param list<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
{
return $this->nativeBuilder ??= new NativeBuilder($this->getCompilerBackend());
@ -2338,7 +2599,28 @@ CODE;
public function genIncludeHeaderFiles(): string
{
$headers = array_merge($this->globalHeaders, [
$globalHeaders = $this->isNanoMode()
? [
'cstring',
'phpx.h',
'phpx_helper.h',
'typephp_helper.h',
'phpx_big_int.h',
'phpx_big_float.h',
'phpx_decimal.h',
'std/core.h',
'std/ctype.h',
'std/array.h',
'std/string.h',
'std/math.h',
'std/json.h',
'std/datetime.h',
'std/hash.h',
'std/misc.h',
'std/random.h',
]
: $this->globalHeaders;
$headers = array_merge($globalHeaders, [
"php_{$this->targetName}_func_decl.h",
"php_{$this->targetName}_data_decl.h",
], $this->localHeaders);
@ -3103,6 +3385,12 @@ CODE;
$ast = $this->parser->parse($phpCode);
$traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false]));
if ($this->isNanoPolicyMode()) {
$traverser->addVisitor(new NanoSyntaxValidationVisitor(
fn (Node $node, string $message) => $this->fatalError($node, $message),
$this->isNanoMode(),
));
}
$traverser->addVisitor(new VoidCastValidationVisitor(
fn (Node $node, string $message) => $this->fatalError($node, $message),
));
@ -4864,7 +5152,7 @@ CODE;
} else {
$isEntryFunction = $this->hasFunction(self::ENTRY_FUNCTION)
&& $functionDef === $this->getFunction(self::ENTRY_FUNCTION);
if ($this->isBuildModeBin() && $isEntryFunction) {
if ($this->isBuildModeBin() && !$this->isNanoMode() && $isEntryFunction) {
// $_SERVER initialization must live inside the main entry function so
// the runtime environment and superglobal context are fully ready
// before it is accessed.
@ -5054,6 +5342,7 @@ CODE;
}
}
$this->initializeImmutableFunctionContext();
$this->prepareReferenceCaptureDegradations($v->stmts, true);
if ($this->functionDef->generator) {
try {
@ -5138,11 +5427,15 @@ CODE;
// Native classes may be discovered after an earlier declaration was
// normalized; the final ABI must use the precise native pointer type,
// not a stale php::Object spelling cached during preprocessing.
$functionDeclCode .= $this->getNativeMethodParameterDeclarations($this->functionDef) . ')';
$functionDeclCode .= $this->getNativeMethodParameterDeclarations(
$this->functionDef,
useDegradedArgumentNames: true,
) . ')';
$code = $functionDeclCode . ' {' . PHP_EOL;
$this->indentLevel++;
$preamble = $this->genScopeVarDecl();
$preamble = $this->genDegradedArgumentLocals();
$preamble .= $this->genScopeVarDecl();
$preamble .= $this->genNativeObjectParameterChecks($this->functionDef);
// Runtime union/nullable parameter type checks
foreach ($this->functionDef->argInfoList as $i => $argInfo) {

@ -3,6 +3,8 @@ use TypePhp\Translator;
use TypePhp\Build\WasiToolchain;
use TypePhp\Build\WasiProjectConfig;
use TypePhp\Build\PhpxLocator;
use TypePhp\Build\NativeSourceProjectBuilder;
use TypePhp\Build\NativeSourceProjectConfig;
use TypePhp\PythonTools\Command as PythonToolsCommand;
use TypePhp\Cli\CompletionCommand;
@ -42,6 +44,11 @@ function main(int $argc, array $argv): void
return;
}
if (shouldCompileNativeSourceProject($argv)) {
compileNativeSourceProject($argv);
return;
}
if (getenv('TYPEPHP_WASM_INTERNAL_COMPILE') !== '1' && shouldCompileWasm($argv)) {
compileWasmProgram($argv);
return;
@ -113,6 +120,84 @@ function main(int $argc, array $argv): void
}
}
function shouldCompileNativeSourceProject(array $argv): bool
{
foreach (array_slice($argv, 1) as $argument) {
if ($argument === '' || $argument[0] === '-') {
continue;
}
$path = $argument;
if ($path[0] !== '/' && preg_match('/^[A-Za-z]:[\\\\\/]/', $path) !== 1) {
$path = getcwd() . DIRECTORY_SEPARATOR . $path;
}
return NativeSourceProjectConfig::isNativeProject($path);
}
return false;
}
function compileNativeSourceProject(array $argv): void
{
$input = null;
$buildDir = null;
$run = false;
$arguments = array_slice($argv, 1);
for ($i = 0, $count = count($arguments); $i < $count; ++$i) {
$argument = $arguments[$i];
if ($argument === '--run' || $argument === '-r') {
$run = true;
continue;
}
if ($argument === '--build-dir') {
if (!isset($arguments[$i + 1]) || $arguments[$i + 1] === '') {
fwrite(STDERR, "Option --build-dir requires a directory\n");
exit(1);
}
$buildDir = $arguments[++$i];
continue;
}
if (str_starts_with($argument, '--build-dir=')) {
$buildDir = substr($argument, strlen('--build-dir='));
if ($buildDir === '') {
fwrite(STDERR, "Option --build-dir requires a directory\n");
exit(1);
}
continue;
}
if ($argument !== '' && $argument[0] === '-') {
fwrite(STDERR, "Unsupported native option: {$argument}\n");
exit(1);
}
if ($input !== null) {
fwrite(STDERR, "Native mode accepts exactly one project.xml\n");
exit(1);
}
$input = $argument;
}
if ($input === null) {
fwrite(STDERR, "Usage: vendor/bin/tpc project.xml [--build-dir DIR] [--run]\n");
exit(1);
}
try {
$project = NativeSourceProjectConfig::load($input, $buildDir);
$builder = new NativeSourceProjectBuilder();
$result = $builder->build($project);
fwrite(
STDOUT,
"Native source build completed: {$result['sourceCount']} source file(s), "
. "{$result['compiledCount']} compiled, "
. "output {$result['output']}\n"
);
if ($run) {
$builder->runOutput($project);
}
} catch (RuntimeException $exception) {
fwrite(STDERR, "Native source build failed: {$exception->getMessage()}\n");
exit(1);
}
}
/**
* Build a self-contained WASI 0.2 command component through the public CLI.
* The lower-level build scripts are implementation details and are not part of
@ -123,12 +208,17 @@ function compileWasmProgram(array $argv): void
$input = null;
$buildDir = null;
$profile = null;
$nano = false;
$arguments = array_slice($argv, 1);
for ($i = 0, $count = count($arguments); $i < $count; $i++) {
$argument = $arguments[$i];
if ($argument === '--wasm') {
continue;
}
if ($argument === '--nano') {
$nano = true;
continue;
}
if (str_starts_with($argument, '--wasm=')) {
$value = substr($argument, strlen('--wasm='));
if ($value === '') {
@ -188,7 +278,8 @@ function compileWasmProgram(array $argv): void
exit(1);
}
$builder = dirname(__DIR__) . '/wasm/build-program.sh';
$builder = dirname(__DIR__) . '/wasm/'
. ($nano ? 'build-nano-program.sh' : 'build-program.sh');
if (!is_executable($builder)) {
fwrite(STDERR, "TypePHP WASI builder is not executable: {$builder}\n");
exit(1);
@ -237,6 +328,7 @@ function compileWasmProgram(array $argv): void
$environment['TYPEPHP_WASM_MODE'] = $project->mode;
$environment['TYPEPHP_WASM_PACKAGE'] = $project->package;
$environment['TYPEPHP_WASM_WORLD'] = $project->world;
$environment['TYPEPHP_WASM_NANO'] = $nano ? '1' : '0';
$compilerExecutable = realpath($argv[0]);
if ($compilerExecutable === false || !is_executable($compilerExecutable)) {
fwrite(STDERR, "Unable to resolve the current TypePHP compiler executable: {$argv[0]}\n");

@ -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