diff --git a/docs/zh-cn/LOCAL_CLOSURE_OPTIMIZATION.md b/docs/zh-cn/LOCAL_CLOSURE_OPTIMIZATION.md
new file mode 100644
index 00000000..351dface
--- /dev/null
+++ b/docs/zh-cn/LOCAL_CLOSURE_OPTIMIZATION.md
@@ -0,0 +1,231 @@
+# 局部非逃逸 Closure 原生化设计
+
+## 1. 背景
+
+TypePHP 当前把所有匿名函数和箭头函数都实现为真正的 Zend `Closure`。编译器先生成
+一个 `php::ClosureFn`,PHPX 再为每个闭包创建:
+
+- `zend_function` 及其参数信息;
+- 保存 C++ 回调、绑定对象和捕获值的 `ClosureCarrier`;
+- 由 `zend_create_fake_closure()` 创建的 Zend `Closure` 对象。
+
+调用闭包时仍需经过 callable 解析、Zend 调用帧和参数数组。这个实现是 PHP 可见
+Closure 的必要兼容路径,但对于只在当前函数内直接调用的闭包,Closure 对象从未被
+观察,以上分配和动态调用没有语义价值。
+
+本优化把编译器能够证明不逃逸的局部闭包生成为具体 C++ lambda,并把其调用点生成为
+直接 C++ 调用。不能证明安全时必须完整回退到现有 Closure Carrier 路径。
+
+## 2. 目标
+
+第一阶段目标如下:
+
+1. 消除局部闭包创建时的 `zend_function`、Carrier 和 Zend Closure 分配;
+2. 消除每次调用的 callable 查询及 Zend execute frame;
+3. 保留 PHP 的闭包创建时捕获、按值复制、可变捕获状态及参数求值顺序;
+4. 不改变任何可能观察 Closure 对象身份或 Zend 调用帧的代码;
+5. 以保守证明为前提,分析失败不产生诊断,只回退到原实现。
+
+本优化属于 TypePHP 编译器内部实现,不新增 PHPX 公共 API。
+
+## 3. 第一阶段适用范围
+
+仅优化以下形态:
+
+```php
+function calculate(int $value): int
+{
+ $offset = 1;
+ $callback = static fn (int $item): int => $item + $offset;
+ return $callback($value);
+}
+```
+
+候选闭包必须同时满足:
+
+- 赋值是函数顶层的一条独立语句;
+- 左值是普通局部变量;
+- 该变量在函数中只有这一个定义;
+- 至少存在一个调用点;
+- 每个读取位置都只能是 `$callback(...)` 的直接调用目标;
+- 赋值在源码和控制流上支配全部调用点;
+- 调用只使用固定位置参数,暂不支持命名参数和 unpack;
+- 参数没有默认值、引用或 variadic;
+- 按值捕获,或对固定 `int/string/float/bool/array` 存储的按引用捕获;
+- 闭包不是 generator,不按引用返回,也不包含嵌套函数或闭包;
+- 闭包体不依赖 Zend 调用帧,例如不调用 `func_get_args()` 或
+ `debug_backtrace()`;
+- 第一阶段只处理全局函数体,不处理类方法中的 `$this`、词法 class scope 和
+ late static binding。
+
+这里的“函数顶层”限制很重要。TypePHP 通常把普通局部变量声明提升到 C++ 函数入口,
+但 C++ lambda 的匿名类型必须通过 `auto` 在初始化位置声明;同时 PHP 的按值捕获发生
+在闭包表达式求值时,不能把初始化提前到函数入口。
+
+## 4. 必须回退的情况
+
+只要 Closure 值可能被 PHP 代码观察,就继续生成真实 Closure:
+
+```php
+return $callback;
+$array[] = $callback;
+$object->callback = $callback;
+$other = $callback;
+array_map($callback, $values);
+is_callable($callback);
+new ReflectionFunction($callback);
+```
+
+以下情况也回退:
+
+- 多次赋值、条件赋值、循环内创建或 `unset()`;
+- 动态 `var-ref` 捕获、引用参数或递归闭包;
+- 不同闭包在同一个变量中汇合;
+- generator Closure;
+- 命名参数、参数展开、variadic 或默认参数;
+- 捕获 Native Object、强类型容器或其他需要专门 GC root 的值;
+- 嵌套闭包以及任何分析器未明确识别的使用方式。
+
+回退不是错误,也不应产生 warning。优化器只能在正向证明完整时启用。
+
+## 5. 生成模型
+
+原始代码:
+
+```php
+$base = 10;
+$callback = static function ($value) use ($base) {
+ return $base + $value;
+};
+$result = $callback(5);
+```
+
+目标代码的结构为:
+
+```cpp
+auto callback = [base = base](php::Var value) mutable -> php::Var {
+ return base + value;
+};
+result = callback(5);
+```
+
+使用显式 init-capture 有两个原因:
+
+1. 捕获在闭包创建位置形成快照,而不是引用外层变量;
+2. `mutable` 允许闭包内部修改自己的捕获副本,并让该状态在多次直接调用之间保留。
+
+`php::Var` 的复制构造会解除普通 PHP reference,符合 `use ($value)` 的按值语义。
+固定 `int/string/float/bool/array` 的 C++ 值则直接复制。第一阶段不接受需要特殊
+生命周期处理的 Native Object 和 std container 捕获。
+
+固定类型的引用捕获不需要先制造 Zend reference。因为分析器已经证明 lambda 不逃逸,
+其生命周期完全包含在外层 C++ 函数帧内,所以:
+
+```php
+$callback = function () use (&$number, &$text, &$items): void {
+ $number++;
+ $text .= '!';
+ $items[] = $number;
+};
+```
+
+可直接生成:
+
+```cpp
+auto callback = [&number, &text, &items]() mutable -> php::Var {
+ // ...
+};
+```
+
+闭包内部把这三个名字登记为对应的 `INT_REF/STR_REF/ARRAY_REF`,继续复用 TypePHP
+现有 typed-ref 赋值检查。这样没有 `php::Ref`、`RefWrap`、`zend_reference` 或写回步骤。
+动态 `php::Var` 引用以及对象、resource、stream、Native Object、std container 等捕获
+仍回退到真实 Zend Closure 路径。
+
+第一阶段继续使用 `php::Var` 作为闭包参数和返回 ABI,以复用现有闭包体生成逻辑并
+降低语义风险。后续可以在独立优化中,根据参数和返回声明把 ABI 收窄到
+`php::Int`、`php::Str` 等固定类型。
+
+## 6. 参数求值顺序
+
+PHP 固定为从左到右求值参数,而 C++17 不保证不同函数实参之间的求值顺序。直接调用
+不能简单输出:
+
+```cpp
+callback(parse(a), parse(b));
+```
+
+多参数调用必须沿用 TypePHP 的 ordered operand 机制,在调用前按源码顺序物化存在
+副作用的值,然后再调用 lambda。第一阶段即使参数表达式较简单,也不能依赖 C++
+编译器碰巧采用的顺序。
+
+## 7. 分析与代码生成
+
+新增局部 Closure 分析器,在函数 SSA 分析完成后、convert 生成语句前运行:
+
+1. 收集函数顶层 `$var = Closure/ArrowFunction` 候选;
+2. 遍历当前函数 AST,分类该变量的每个定义和使用;
+3. 任一使用不是直接调用目标即淘汰候选;
+4. 检查闭包和调用参数是否位于第一阶段支持集合;
+5. 把证明结果写入当前 `FunctionContext`;
+6. 赋值生成器在原始语句位置输出 `auto` lambda;
+7. 函数调用生成器识别该局部变量并输出直接调用;
+8. 普通局部变量声明生成器跳过已经在源码位置声明的 lambda。
+
+分析数据只属于当前函数的 `FunctionContext`,不得写入 persistent/request runtime
+cache,也不得跨函数复用。
+
+## 8. 安全不变量
+
+实现必须始终满足:
+
+1. **无误优化**:不能证明时回退,绝不猜测;
+2. **创建时捕获**:不能把 lambda 初始化提升到原 PHP 表达式之前;
+3. **生命周期包含**:lambda 及全部调用点位于同一 C++ 函数帧;
+4. **无 PHP 可见身份**:优化后的值不能进入 zval、数组、属性、参数或返回值;
+5. **求值顺序一致**:实参和闭包体中的副作用顺序必须与 PHP 一致;
+6. **异常边界一致**:PHP 异常仍通过现有 C++ `zend_object *` 路径传播;
+7. **回退等价**:移除优化标记后,同一源码必须仍可由 Carrier 路径编译运行。
+
+## 9. 测试计划
+
+正向测试至少覆盖:
+
+- 无捕获箭头函数;
+- 按值捕获及创建时快照;
+- 捕获副本在多次调用之间保持内部修改;
+- 固定 int/string/float/bool/array 的直接引用捕获;
+- 普通匿名函数;
+- 多次直接调用;
+- 参数表达式的左到右求值。
+
+回退测试至少覆盖:
+
+- 作为参数传给 `array_map()`;
+- 从函数返回;
+- 写入数组或属性;
+- 赋给另一个变量;
+- 动态 `var-ref` 引用捕获;
+- 默认参数、variadic、unpack 和命名参数;
+- generator、递归及嵌套 Closure;
+- class method 中的 `$this`;
+- `ReflectionFunction`、`is_callable()` 等身份观察。
+
+生成代码测试需要同时断言:正向候选不包含
+`newClosureWithParameters`/`ClosureCarrier` 调用,而回退用例仍包含真实 Closure 创建。
+
+## 10. 性能验证
+
+`benchmark/dynamic-call/benchmark.php` 中的 `closure_monomorphic` 是第一阶段的主要性能
+用例。还应保留 `closure_alternating` 作为不能原生化的对照组。
+
+验证指标包括:
+
+- Closure 创建次数和堆分配;
+- 每次调用耗时;
+- TypePHP/Zend PHP 比值;
+- 生成 C++ 大小及编译时间;
+- checksum 与 Zend PHP 一致。
+
+性能提升不是放宽安全条件的理由。任何会暴露 Closure 对象或无法静态证明的场景,
+即使处于热点,也继续使用 Closure Carrier。
diff --git a/examples/objective-c-macos/Info-iOS.plist b/examples/objective-c-macos/Info-iOS.plist
index 61df13f3..b123edc8 100644
--- a/examples/objective-c-macos/Info-iOS.plist
+++ b/examples/objective-c-macos/Info-iOS.plist
@@ -12,14 +12,28 @@
org.swoole.typephp.ios-hello
CFBundleInfoDictionaryVersion
6.0
+ CFBundleIcons
+
+ CFBundlePrimaryIcon
+
+ CFBundleIconFiles
+
+ AppIcon60x60
+
+
+
+ CFBundleIconFiles
+
+ AppIcon60x60
+
CFBundleName
TypePHP iOS Hello
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.1.0
+ 0.2.0
CFBundleVersion
- 1
+ 2
LSRequiresIPhoneOS
MinimumOSVersion
diff --git a/examples/objective-c-macos/README.md b/examples/objective-c-macos/README.md
index 7ff7515c..aa58cda6 100644
--- a/examples/objective-c-macos/README.md
+++ b/examples/objective-c-macos/README.md
@@ -66,13 +66,29 @@ sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
xcrun --sdk iphoneos --show-sdk-path
```
-Install this SDK at `ios/iphoneos-arm64` inside the PHPX checkout, matching the
-integrated layouts already used by `full-static/sdk` and
-`wasm/wasm32-wasip2`. Build the executable from the TypePHP repository root;
-`PHPX_HOME` selects both PHPX sources and the target SDK:
+Build the SDK with swoole-cli. PHP source updates must go through
+`sync-source-code.php`; this also validates and synchronizes the generated Zend
+parser/scanner sources from the official php.net release archive:
```sh
-export PHPX_HOME=/path/to/phpx
+cd /path/to/swoole-cli
+php sync-source-code.php --action run
+php prepare.php @iphoneos-arm64 --with-parallel-jobs=8
+./make.sh all-library
+./make.sh config
+./make.sh libphp
+./make.sh phpx
+./make.sh sdk
+```
+
+The installed SDK is under
+`thirdparty/phpx/ios/iphoneos-arm64`. The `php-version` major in `ios.yml` must
+match `sapi/PHP-VERSION.conf` used by swoole-cli. Build the executable from the
+TypePHP repository root; `PHPX_HOME` selects both PHPX sources and the target
+SDK:
+
+```sh
+export PHPX_HOME=/path/to/swoole-cli/thirdparty/phpx
php bin/tpc.php examples/objective-c-macos/ios.yml --no-progress
```
@@ -83,9 +99,19 @@ the login keychain:
```sh
export TYPEPHP_IOS_PROVISIONING_PROFILE=/path/to/profile.mobileprovision
export TYPEPHP_IOS_CODE_SIGN_IDENTITY='Apple Development: Your Name (TEAMID)'
+# Set this when the profile uses a different identifier than the example.
+export TYPEPHP_IOS_BUNDLE_IDENTIFIER='your.provisioned.bundle.identifier'
sh examples/objective-c-macos/package-ios-app.sh
```
+The package includes iPhone icon sizes generated from the repository's
+`swoole-logo.svg`. To regenerate them after changing the logo, install FFmpeg
+and run:
+
+```sh
+sh examples/objective-c-macos/generate-ios-icons.sh
+```
+
Find the connected iPhone and install the bundle:
```sh
diff --git a/examples/objective-c-macos/generate-ios-icons.sh b/examples/objective-c-macos/generate-ios-icons.sh
new file mode 100755
index 00000000..a2a03d42
--- /dev/null
+++ b/examples/objective-c-macos/generate-ios-icons.sh
@@ -0,0 +1,42 @@
+#!/bin/sh
+
+set -eu
+
+project_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+source_icon="$project_dir/ios-assets/AppIcon.svg"
+logo_source="$project_dir/../../swoole-logo.svg"
+output_dir="$project_dir/ios-assets"
+master_icon="$output_dir/AppIcon-1024.png"
+temporary_dir=$(mktemp -d "${TMPDIR:-/tmp}/typephp-ios-icon.XXXXXX")
+temporary_logo="$temporary_dir/swoole-logo.svg"
+trap 'rm -rf "$temporary_dir"' EXIT HUP INT TERM
+
+if ! command -v ffmpeg >/dev/null 2>&1; then
+ echo "FFmpeg is required to render the SVG app icon." >&2
+ exit 1
+fi
+
+# Override only the SVG viewport size. Its vector paths remain unchanged and
+# are rasterized directly at the size used by the 1024px master artwork.
+sed 's/width="100px" height="60px"/width="820px" height="282px"/' \
+ "$logo_source" > "$temporary_logo"
+
+ffmpeg -hide_banner -loglevel error -y \
+ -i "$source_icon" \
+ -i "$temporary_logo" \
+ -filter_complex '[0:v][1:v]overlay=102:371:format=auto,format=rgb24' \
+ -frames:v 1 \
+ "$master_icon"
+
+for size in 40 58 60 80 87 120 180; do
+ ffmpeg -hide_banner -loglevel error -y \
+ -i "$master_icon" \
+ -vf "scale=${size}:${size}:flags=lanczos" \
+ -frames:v 1 -pix_fmt rgb24 \
+ "$output_dir/AppIcon-${size}.png"
+done
+
+cp "$output_dir/AppIcon-120.png" "$output_dir/AppIcon60x60@2x.png"
+cp "$output_dir/AppIcon-180.png" "$output_dir/AppIcon60x60@3x.png"
+
+echo "Generated iOS icons in $output_dir"
diff --git a/examples/objective-c-macos/ios-assets/AppIcon-1024.png b/examples/objective-c-macos/ios-assets/AppIcon-1024.png
new file mode 100644
index 00000000..f0590f51
Binary files /dev/null and b/examples/objective-c-macos/ios-assets/AppIcon-1024.png differ
diff --git a/examples/objective-c-macos/ios-assets/AppIcon-120.png b/examples/objective-c-macos/ios-assets/AppIcon-120.png
new file mode 100644
index 00000000..4604ac21
Binary files /dev/null and b/examples/objective-c-macos/ios-assets/AppIcon-120.png differ
diff --git a/examples/objective-c-macos/ios-assets/AppIcon-180.png b/examples/objective-c-macos/ios-assets/AppIcon-180.png
new file mode 100644
index 00000000..a9af72ea
Binary files /dev/null and b/examples/objective-c-macos/ios-assets/AppIcon-180.png differ
diff --git a/examples/objective-c-macos/ios-assets/AppIcon-40.png b/examples/objective-c-macos/ios-assets/AppIcon-40.png
new file mode 100644
index 00000000..c66f70d0
Binary files /dev/null and b/examples/objective-c-macos/ios-assets/AppIcon-40.png differ
diff --git a/examples/objective-c-macos/ios-assets/AppIcon-58.png b/examples/objective-c-macos/ios-assets/AppIcon-58.png
new file mode 100644
index 00000000..67a69d79
Binary files /dev/null and b/examples/objective-c-macos/ios-assets/AppIcon-58.png differ
diff --git a/examples/objective-c-macos/ios-assets/AppIcon-60.png b/examples/objective-c-macos/ios-assets/AppIcon-60.png
new file mode 100644
index 00000000..48a36f4a
Binary files /dev/null and b/examples/objective-c-macos/ios-assets/AppIcon-60.png differ
diff --git a/examples/objective-c-macos/ios-assets/AppIcon-80.png b/examples/objective-c-macos/ios-assets/AppIcon-80.png
new file mode 100644
index 00000000..a2cc2b0d
Binary files /dev/null and b/examples/objective-c-macos/ios-assets/AppIcon-80.png differ
diff --git a/examples/objective-c-macos/ios-assets/AppIcon-87.png b/examples/objective-c-macos/ios-assets/AppIcon-87.png
new file mode 100644
index 00000000..3fcd3d63
Binary files /dev/null and b/examples/objective-c-macos/ios-assets/AppIcon-87.png differ
diff --git a/examples/objective-c-macos/ios-assets/AppIcon.svg b/examples/objective-c-macos/ios-assets/AppIcon.svg
new file mode 100644
index 00000000..beaa2270
--- /dev/null
+++ b/examples/objective-c-macos/ios-assets/AppIcon.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/examples/objective-c-macos/ios-assets/AppIcon60x60@2x.png b/examples/objective-c-macos/ios-assets/AppIcon60x60@2x.png
new file mode 100644
index 00000000..4604ac21
Binary files /dev/null and b/examples/objective-c-macos/ios-assets/AppIcon60x60@2x.png differ
diff --git a/examples/objective-c-macos/ios-assets/AppIcon60x60@3x.png b/examples/objective-c-macos/ios-assets/AppIcon60x60@3x.png
new file mode 100644
index 00000000..a9af72ea
Binary files /dev/null and b/examples/objective-c-macos/ios-assets/AppIcon60x60@3x.png differ
diff --git a/examples/objective-c-macos/ios-src/uikit_bridge.mm b/examples/objective-c-macos/ios-src/uikit_bridge.mm
index 7a9dea96..303a5a74 100644
--- a/examples/objective-c-macos/ios-src/uikit_bridge.mm
+++ b/examples/objective-c-macos/ios-src/uikit_bridge.mm
@@ -117,6 +117,7 @@ php::Int php_ui_add_label(
? [UIFont systemFontOfSize:static_cast(fontSize)
weight:UIFontWeightSemibold]
: [UIFont systemFontOfSize:static_cast(fontSize)];
+ label.textColor = bold ? UIColor.labelColor : UIColor.secondaryLabelColor;
label.textAlignment = NSTextAlignmentCenter;
label.numberOfLines = 0;
label.adjustsFontSizeToFitWidth = YES;
@@ -131,15 +132,31 @@ php::Int php_ui_add_button(
php::Int x,
php::Int y,
php::Int width,
- php::Int height)
+ php::Int height,
+ php::Int style)
{
@autoreleasepool {
UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
button.frame = logical_rect(x, y, width, height);
- [button setTitle:to_ns_string(title) forState:UIControlStateNormal];
- button.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
- button.backgroundColor = UIColor.secondarySystemBackgroundColor;
- button.layer.cornerRadius = 10;
+ UIButtonConfiguration *configuration;
+ if (style == 1) {
+ configuration = [UIButtonConfiguration filledButtonConfiguration];
+ configuration.baseBackgroundColor = UIColor.systemBlueColor;
+ configuration.baseForegroundColor = UIColor.whiteColor;
+ } else if (style == 2) {
+ configuration = [UIButtonConfiguration tintedButtonConfiguration];
+ configuration.baseBackgroundColor = UIColor.systemBlueColor;
+ } else {
+ configuration = [UIButtonConfiguration grayButtonConfiguration];
+ }
+ configuration.title = to_ns_string(title);
+ configuration.cornerStyle = UIButtonConfigurationCornerStyleLarge;
+ configuration.contentInsets = NSDirectionalEdgeInsetsMake(12, 18, 12, 18);
+ button.configuration = configuration;
+ button.titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
+ button.titleLabel.adjustsFontSizeToFitWidth = YES;
+ button.titleLabel.minimumScaleFactor = 0.75;
+ button.titleLabel.lineBreakMode = NSLineBreakByTruncatingTail;
[button addTarget:bridge
action:@selector(controlActivated:)
forControlEvents:UIControlEventTouchUpInside];
@@ -156,7 +173,15 @@ void php_ui_set_control_text(php::Int controlId, php::Str text)
if ([control isKindOfClass:UILabel.class]) {
((UILabel *) control).text = value;
} else if ([control isKindOfClass:UIButton.class]) {
- [(UIButton *) control setTitle:value forState:UIControlStateNormal];
+ UIButton *button = (UIButton *) control;
+ UIButtonConfiguration *configuration = button.configuration;
+ if (configuration != nil) {
+ configuration.title = value;
+ button.configuration = configuration;
+ } else {
+ [button setTitle:value forState:UIControlStateNormal];
+ }
+ button.accessibilityLabel = value;
}
}
}
diff --git a/examples/objective-c-macos/ios-src/uikit_bridge.stub.php b/examples/objective-c-macos/ios-src/uikit_bridge.stub.php
index 7ec56f2a..20aea9af 100644
--- a/examples/objective-c-macos/ios-src/uikit_bridge.stub.php
+++ b/examples/objective-c-macos/ios-src/uikit_bridge.stub.php
@@ -16,7 +16,7 @@ function ui_add_label(
bool $bold
): int {}
-function ui_add_button(string $title, int $x, int $y, int $width, int $height): int {}
+function ui_add_button(string $title, int $x, int $y, int $width, int $height, int $style): int {}
function ui_set_control_text(int $controlId, string $text): void {}
diff --git a/examples/objective-c-macos/ios.yml b/examples/objective-c-macos/ios.yml
index 2d77596f..db3be067 100644
--- a/examples/objective-c-macos/ios.yml
+++ b/examples/objective-c-macos/ios.yml
@@ -2,7 +2,7 @@ name: typephp_ios_hello
version: 0.1.0
mode: bin
output: typephp_ios_hello
-php-version: "8.5"
+php-version: "8.4"
cxx-std: c++17
cpp-compiler: xcrun --sdk iphoneos clang++
target-platform: arm64-apple-ios15.0
@@ -22,3 +22,5 @@ ld-flags:
- -Wl,-dead_strip
- -framework UIKit
- -framework Foundation
+ - -framework CoreGraphics
+ - -lresolv
diff --git a/examples/objective-c-macos/objc-src/macos_bridge.mm b/examples/objective-c-macos/objc-src/macos_bridge.mm
index cf12ad88..5ef4e1b8 100644
--- a/examples/objective-c-macos/objc-src/macos_bridge.mm
+++ b/examples/objective-c-macos/objc-src/macos_bridge.mm
@@ -135,7 +135,8 @@ php::Int php_ui_add_button(
php::Int x,
php::Int y,
php::Int width,
- php::Int height)
+ php::Int height,
+ php::Int style)
{
@autoreleasepool {
NSButton *button = [NSButton buttonWithTitle:to_ns_string(title)
@@ -147,7 +148,13 @@ php::Int php_ui_add_button(
static_cast(width),
static_cast(height));
button.bezelStyle = NSBezelStyleRounded;
- button.keyEquivalent = @"\r";
+ button.controlSize = NSControlSizeLarge;
+ button.font = [NSFont systemFontOfSize:14 weight:NSFontWeightSemibold];
+ if (style == 1) {
+ button.bezelColor = NSColor.controlAccentColor;
+ button.contentTintColor = NSColor.whiteColor;
+ button.keyEquivalent = @"\r";
+ }
button.autoresizingMask = NSViewMinXMargin | NSViewMaxXMargin | NSViewMinYMargin;
[bridge.window.contentView addSubview:button];
return static_cast(store_control(button));
diff --git a/examples/objective-c-macos/objc-src/macos_bridge.stub.php b/examples/objective-c-macos/objc-src/macos_bridge.stub.php
index 4afc0e9c..dacd8303 100644
--- a/examples/objective-c-macos/objc-src/macos_bridge.stub.php
+++ b/examples/objective-c-macos/objc-src/macos_bridge.stub.php
@@ -15,7 +15,7 @@ function ui_add_label(
bool $bold
): int {}
-function ui_add_button(string $title, int $x, int $y, int $width, int $height): int {}
+function ui_add_button(string $title, int $x, int $y, int $width, int $height, int $style): int {}
function ui_set_control_text(int $controlId, string $text): void {}
diff --git a/examples/objective-c-macos/package-ios-app.sh b/examples/objective-c-macos/package-ios-app.sh
index 75991178..a1be434b 100755
--- a/examples/objective-c-macos/package-ios-app.sh
+++ b/examples/objective-c-macos/package-ios-app.sh
@@ -7,6 +7,7 @@ executable="$project_dir/typephp_ios_hello"
bundle="$project_dir/dist/TypePHP iOS Hello.app"
profile=${TYPEPHP_IOS_PROVISIONING_PROFILE:-}
identity=${TYPEPHP_IOS_CODE_SIGN_IDENTITY:-}
+bundle_identifier=${TYPEPHP_IOS_BUNDLE_IDENTIFIER:-}
if [ ! -x "$executable" ]; then
echo "Missing $executable; build ios.yml first." >&2
@@ -33,8 +34,12 @@ plutil -extract Entitlements xml1 -o "$entitlements" "$profile_plist"
rm -rf "$bundle"
mkdir -p "$bundle"
cp "$project_dir/Info-iOS.plist" "$bundle/Info.plist"
+if [ -n "$bundle_identifier" ]; then
+ plutil -replace CFBundleIdentifier -string "$bundle_identifier" "$bundle/Info.plist"
+fi
cp "$executable" "$bundle/typephp_ios_hello"
cp "$profile" "$bundle/embedded.mobileprovision"
+cp "$project_dir"/ios-assets/AppIcon*.png "$bundle/"
codesign \
--force \
diff --git a/examples/objective-c-macos/php-src/application.php b/examples/objective-c-macos/php-src/application.php
index c6afffde..0f104f4d 100644
--- a/examples/objective-c-macos/php-src/application.php
+++ b/examples/objective-c-macos/php-src/application.php
@@ -2,40 +2,106 @@
final class HelloApplication
{
+ private const BUTTON_PRIMARY = 1;
+ private const BUTTON_SECONDARY = 2;
+ private const BUTTON_TERTIARY = 3;
+
+ private static int $titleLabel = 0;
+ private static int $descriptionLabel = 0;
private static int $statusLabel = 0;
- private static int $button = 0;
+ private static int $countButton = 0;
+ private static int $resetButton = 0;
+ private static int $languageButton = 0;
private static int $clickCount = 0;
+ private static bool $isChinese = false;
public static function build(string $platformApi): void
{
- ui_create_window('TypePHP Native Hello', 640, 380);
- ui_add_label('TypePHP + Objective-C++', 44, 292, 552, 40, 28, true);
- ui_add_label(
- "The interface and application state are implemented in TypePHP.\n" .
- "Objective-C++ only provides thin wrappers around $platformApi.",
- 64,
- 178,
- 512,
- 88,
- 15,
- false,
- );
-
- self::$statusLabel = ui_add_label('Waiting for a native UI event.', 64, 130, 512, 24, 13, false);
- self::$button = ui_add_button('Send event to TypePHP', 210, 64, 220, 40);
+ if ($platformApi === 'UIKit') {
+ self::buildPhoneLayout();
+ } else {
+ self::buildDesktopLayout();
+ }
+ self::renderText();
}
public static function handleEvent(int $controlId): void
{
- if ($controlId !== self::$button) {
+ if ($controlId === self::$languageButton) {
+ self::$isChinese = !self::$isChinese;
+ self::renderText();
+ return;
+ }
+
+ if ($controlId === self::$resetButton) {
+ self::$clickCount = 0;
+ self::renderStatus();
+ return;
+ }
+
+ if ($controlId !== self::$countButton) {
return;
}
self::$clickCount++;
- $suffix = self::$clickCount === 1 ? 'time' : 'times';
- ui_set_control_text(
- self::$statusLabel,
- 'Button clicked ' . self::$clickCount . " $suffix; handled by TypePHP.",
- );
+ self::renderStatus();
+ }
+
+ private static function buildPhoneLayout(): void
+ {
+ ui_create_window('TypePHP Native Hello', 390, 844);
+ self::$titleLabel = ui_add_label('', 24, 672, 342, 76, 30, true);
+ self::$descriptionLabel = ui_add_label('', 30, 544, 330, 104, 16, false);
+ self::$statusLabel = ui_add_label('', 30, 454, 330, 58, 18, true);
+ self::$countButton = ui_add_button('', 30, 354, 330, 58, self::BUTTON_PRIMARY);
+ self::$resetButton = ui_add_button('', 30, 280, 158, 52, self::BUTTON_SECONDARY);
+ self::$languageButton = ui_add_button('', 202, 280, 158, 52, self::BUTTON_TERTIARY);
+ }
+
+ private static function buildDesktopLayout(): void
+ {
+ ui_create_window('TypePHP Native Hello', 640, 420);
+ self::$titleLabel = ui_add_label('', 44, 336, 552, 40, 28, true);
+ self::$descriptionLabel = ui_add_label('', 64, 222, 512, 82, 15, false);
+ self::$statusLabel = ui_add_label('', 64, 164, 512, 30, 16, true);
+ self::$countButton = ui_add_button('', 160, 92, 320, 46, self::BUTTON_PRIMARY);
+ self::$resetButton = ui_add_button('', 160, 34, 150, 40, self::BUTTON_SECONDARY);
+ self::$languageButton = ui_add_button('', 330, 34, 150, 40, self::BUTTON_TERTIARY);
+ }
+
+ private static function renderText(): void
+ {
+ if (self::$isChinese) {
+ ui_set_control_text(self::$titleLabel, 'TypePHP 原生应用');
+ ui_set_control_text(
+ self::$descriptionLabel,
+ "界面逻辑和应用状态均由 TypePHP 实现。\nObjective-C++ 仅提供轻量的原生 UI 桥接。",
+ );
+ ui_set_control_text(self::$countButton, '点击计数');
+ ui_set_control_text(self::$resetButton, '重置');
+ ui_set_control_text(self::$languageButton, 'English');
+ } else {
+ ui_set_control_text(self::$titleLabel, 'TypePHP Native App');
+ ui_set_control_text(
+ self::$descriptionLabel,
+ "Application logic and state are implemented in TypePHP.\n" .
+ 'Objective-C++ provides only a thin native UI bridge.',
+ );
+ ui_set_control_text(self::$countButton, 'Count with TypePHP');
+ ui_set_control_text(self::$resetButton, 'Reset');
+ ui_set_control_text(self::$languageButton, '中文');
+ }
+ self::renderStatus();
+ }
+
+ private static function renderStatus(): void
+ {
+ if (self::$isChinese) {
+ ui_set_control_text(self::$statusLabel, '已点击 ' . self::$clickCount . ' 次');
+ return;
+ }
+
+ $suffix = self::$clickCount === 1 ? 'click' : 'clicks';
+ ui_set_control_text(self::$statusLabel, self::$clickCount . " $suffix");
}
}
diff --git a/phpunit/code/local-native-closure-codegen.php b/phpunit/code/local-native-closure-codegen.php
new file mode 100644
index 00000000..46adc729
--- /dev/null
+++ b/phpunit/code/local-native-closure-codegen.php
@@ -0,0 +1,23 @@
+ $base + $value;
+ var_dump($direct(2));
+
+ $escaped = static fn (int $value): int => $value + 1;
+ array_map($escaped, [1]);
+
+ $dynamic = std::any(1);
+ $dynamicRef = function () use (&$dynamic): void {
+ $dynamic++;
+ };
+ $dynamicRef();
+}
diff --git a/phpunit/src/Build/PrecompiledHeaderManagerTest.php b/phpunit/src/Build/PrecompiledHeaderManagerTest.php
index 72df23b3..7af56606 100644
--- a/phpunit/src/Build/PrecompiledHeaderManagerTest.php
+++ b/phpunit/src/Build/PrecompiledHeaderManagerTest.php
@@ -62,6 +62,39 @@ final class PrecompiledHeaderManagerTest extends TestCase
$this->assertCount(8, $managedEntries);
}
+ public function testDependencyMtimeChangeInvalidatesCachedArtifact(): void
+ {
+ $dependencyDirectory = $this->cacheDirectory . '/dependencies';
+ mkdir($dependencyDirectory);
+ $dependency = $dependencyDirectory . '/runtime.h';
+ file_put_contents($dependency, "#pragma once\n");
+
+ $backend = $this->createMock(CompilerBackend::class);
+ $backend->method('supportsPrecompiledHeaders')->willReturn(true);
+ $backend->method('getName')->willReturn('test');
+ $backend->method('getCompilerCommand')->willReturn('true');
+ $backend->method('getPrecompiledHeaderArtifact')
+ ->willReturnCallback(static fn(string $header): string => $header . '.gch');
+ $backend->method('buildNativeCompileCommand')
+ ->willReturnCallback(
+ static fn(string $source, string $object): string => 'touch ' . escapeshellarg($object),
+ );
+
+ $manager = new PrecompiledHeaderManager($backend, new NativeBuilder($backend));
+ $options = new CompileOptions([]);
+ $first = $manager->prepare(['runtime.h'], [$dependencyDirectory], $this->cacheDirectory, $options);
+ $cached = $manager->prepare(['runtime.h'], [$dependencyDirectory], $this->cacheDirectory, $options);
+
+ touch($dependency, filemtime($dependency) + 10);
+ clearstatcache(true, $dependency);
+ $refreshed = $manager->prepare(['runtime.h'], [$dependencyDirectory], $this->cacheDirectory, $options);
+
+ $this->assertFalse($first['cached']);
+ $this->assertTrue($cached['cached']);
+ $this->assertFalse($refreshed['cached']);
+ $this->assertNotSame($first['artifact'], $refreshed['artifact']);
+ }
+
private function createCacheEntry(int $number, int $mtime): string
{
$directory = $this->cacheDirectory . '/' . sprintf('%024x', $number);
diff --git a/phpunit/src/ClosureTest.php b/phpunit/src/ClosureTest.php
index 5dd4c845..4f99fa25 100644
--- a/phpunit/src/ClosureTest.php
+++ b/phpunit/src/ClosureTest.php
@@ -17,8 +17,9 @@ class ClosureTest extends \BaseTest
$code = file_get_contents($generated);
self::assertIsString($code);
- self::assertSame(3, substr_count($code, 'php::newClosureWithParameters('));
- self::assertSame(3, substr_count($code, 'php::ClosureStrictTypes::Enabled'));
+ 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'));
}
public function testClosureRebindingIsRejectedAtCompileTime(): void
diff --git a/phpunit/src/FixedReferenceStorageTest.php b/phpunit/src/FixedReferenceStorageTest.php
index 17e2058e..bffe79b4 100644
--- a/phpunit/src/FixedReferenceStorageTest.php
+++ b/phpunit/src/FixedReferenceStorageTest.php
@@ -16,14 +16,30 @@ final class FixedReferenceStorageTest extends BaseTest
public static function fixedStorageProvider(): iterable
{
- yield 'string' => ['fixed-reference-string.php', 'php::Str'];
- yield 'array' => ['fixed-reference-array.php', 'php::Array'];
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'];
}
+ /** @dataProvider localClosureFixedReferenceProvider */
+ public function testNonEscapingLocalClosureUsesNativeReferenceCapture(
+ string $fixture,
+ string $capture,
+ ): void
+ {
+ $code = $this->compileFixture($fixture);
+
+ self::assertStringContainsString($capture, $code);
+ self::assertStringNotContainsString('php::newClosureWithParameters(', $code);
+ }
+
+ public static function localClosureFixedReferenceProvider(): iterable
+ {
+ yield 'string' => ['fixed-reference-string.php', 'auto closure = [&value]() mutable -> php::Var {'];
+ yield 'array' => ['fixed-reference-array.php', 'auto closure = [&value]() mutable -> php::Var {'];
+ }
+
public function testFixedStorageUsesNativeReferenceForKnownTypedParameter(): void
{
$code = $this->compileFixture('fixed-reference-argument.php');
diff --git a/phpunit/src/LocalClosureCodegenTest.php b/phpunit/src/LocalClosureCodegenTest.php
new file mode 100644
index 00000000..5c271f07
--- /dev/null
+++ b/phpunit/src/LocalClosureCodegenTest.php
@@ -0,0 +1,41 @@
+addFiles([$source]);
+ $compiler->prepareFile($source);
+ $generated = $compiler->convertFile($source);
+ $code = file_get_contents($generated);
+
+ self::assertIsString($code);
+ self::assertStringContainsString(
+ 'auto direct = [base = base](php::Var value) mutable -> php::Var {',
+ $code,
+ );
+ 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);
+ }
+}
diff --git a/phpunit/src/VarIntTypesTest.php b/phpunit/src/VarIntTypesTest.php
index 3059f69b..faae9d3e 100644
--- a/phpunit/src/VarIntTypesTest.php
+++ b/phpunit/src/VarIntTypesTest.php
@@ -71,17 +71,18 @@ final class VarIntTypesTest extends BaseTest
}
}
- public function testNativeScalarCannotBePromotedForReferenceCapture(): void
+ public function testNativeScalarUsesDirectReferenceCaptureInNonEscapingClosure(): void
{
- $this->expectException(TestError::class);
- $this->expectExceptionMessage(
- 'Cannot create a reference to variable $changed of fixed type php::Bool; initialize it with std::any()',
- );
-
- $this->compileSource(
+ $code = $this->compileSource(
$this->createCompiler(),
TYPEPHP_ROOT_PATH . '/phpunit/code/native-scalar-reference-capture.php',
);
+
+ self::assertStringContainsString(
+ 'auto set = [&changed]() mutable -> php::Var {',
+ $code,
+ );
+ self::assertStringNotContainsString('php::newClosureWithParameters(', $code);
}
public function testExplicitAnySupportsReferenceCapture(): void
diff --git a/src/Analysis/LocalClosureAnalyzer.php b/src/Analysis/LocalClosureAnalyzer.php
new file mode 100644
index 00000000..da88b509
--- /dev/null
+++ b/src/Analysis/LocalClosureAnalyzer.php
@@ -0,0 +1,222 @@
+ */
+ private array $candidates = [];
+
+ /** @var array */
+ private array $invalid = [];
+
+ /** @var array */
+ private array $defined = [];
+
+ /**
+ * @param list $statements
+ * @return array
+ */
+ public function analyze(array $statements): array
+ {
+ $this->candidates = [];
+ $this->invalid = [];
+ $this->defined = [];
+
+ $duplicateNames = [];
+ foreach ($statements as $statement) {
+ if (!$statement instanceof Stmt\Expression
+ || !$statement->expr instanceof Expr\Assign
+ || !$statement->expr->var instanceof Expr\Variable
+ || !is_string($statement->expr->var->name)
+ || (!$statement->expr->expr instanceof Expr\Closure
+ && !$statement->expr->expr instanceof Expr\ArrowFunction)
+ ) {
+ continue;
+ }
+
+ $name = $statement->expr->var->name;
+ if (isset($this->candidates[$name])) {
+ $duplicateNames[$name] = true;
+ continue;
+ }
+ if (!$this->isSupportedClosure($statement->expr->expr)) {
+ continue;
+ }
+ $this->candidates[$name] = [
+ 'assignment' => $statement->expr,
+ 'closure' => $statement->expr->expr,
+ 'calls' => 0,
+ ];
+ }
+
+ foreach ($duplicateNames as $name => $_) {
+ unset($this->candidates[$name]);
+ }
+ if ($this->candidates === []) {
+ return [];
+ }
+
+ foreach ($statements as $statement) {
+ $this->scanNode($statement);
+ }
+
+ foreach ($this->candidates as $name => $candidate) {
+ if (isset($this->invalid[$name]) || $candidate['calls'] === 0) {
+ unset($this->candidates[$name]);
+ }
+ }
+ return $this->candidates;
+ }
+
+ private function isSupportedClosure(Expr\Closure|Expr\ArrowFunction $closure): bool
+ {
+ if ($closure->byRef) {
+ return false;
+ }
+ if ($closure->returnType instanceof Node\Identifier
+ && strtolower($closure->returnType->name) === 'never'
+ ) {
+ return false;
+ }
+ foreach ($closure->params as $parameter) {
+ if ($parameter->byRef || $parameter->variadic || $parameter->default !== null) {
+ return false;
+ }
+ }
+ $body = $closure instanceof Expr\ArrowFunction ? $closure->expr : $closure->stmts;
+ return !$this->containsUnsupportedClosureNode($body, false);
+ }
+
+ private function containsUnsupportedClosureNode(mixed $value, bool $root = true): bool
+ {
+ foreach (is_array($value) ? $value : [$value] as $node) {
+ if (!$node instanceof Node) {
+ continue;
+ }
+ if (!$root && $node instanceof FunctionLike) {
+ return true;
+ }
+ if ($node instanceof Expr\Yield_
+ || $node instanceof Expr\YieldFrom
+ || $node instanceof Stmt\Static_
+ || $node instanceof Stmt\Global_
+ ) {
+ return true;
+ }
+ if ($node instanceof Expr\FuncCall && $node->name instanceof Node\Name) {
+ $name = strtolower(ltrim($node->name->toString(), '\\'));
+ if (in_array($name, [
+ 'func_get_arg',
+ 'func_get_args',
+ 'func_num_args',
+ 'debug_backtrace',
+ 'debug_print_backtrace',
+ ], true)) {
+ return true;
+ }
+ }
+ foreach ($node->getSubNodeNames() as $field) {
+ if ($this->containsUnsupportedClosureNode($node->{$field}, false)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private function scanNode(
+ mixed $value,
+ ?Node $parent = null,
+ string $parentField = '',
+ int $functionDepth = 0,
+ ): void {
+ foreach (is_array($value) ? $value : [$value] as $node) {
+ if (!$node instanceof Node) {
+ continue;
+ }
+
+ // Textual order is not a dominance proof in the presence of goto:
+ // a jump may bypass the lambda initialization or re-enter its
+ // scope. Keep all such functions on the Zend Closure path.
+ if ($node instanceof Stmt\Goto_ || $node instanceof Stmt\Label) {
+ foreach ($this->candidates as $name => $_candidate) {
+ $this->invalid[$name] = true;
+ }
+ return;
+ }
+
+ if ($node instanceof Expr\Variable && is_string($node->name)) {
+ $this->classifyVariableUse($node->name, $parent, $parentField, $functionDepth);
+ }
+
+ $childFunctionDepth = $functionDepth + ($node instanceof FunctionLike ? 1 : 0);
+ foreach ($node->getSubNodeNames() as $field) {
+ $this->scanNode($node->{$field}, $node, $field, $childFunctionDepth);
+ }
+ }
+ }
+
+ private function classifyVariableUse(
+ string $name,
+ ?Node $parent,
+ string $parentField,
+ int $functionDepth,
+ ): void {
+ if (!isset($this->candidates[$name]) || isset($this->invalid[$name])) {
+ return;
+ }
+
+ $candidate = $this->candidates[$name];
+ if ($parent instanceof Expr\Assign && $parentField === 'var') {
+ if ($parent === $candidate['assignment'] && !isset($this->defined[$name])) {
+ $this->defined[$name] = true;
+ return;
+ }
+ $this->invalid[$name] = true;
+ return;
+ }
+
+ if ($functionDepth !== 0
+ || !$parent instanceof Expr\FuncCall
+ || $parentField !== 'name'
+ || $parent->isFirstClassCallable()
+ || !isset($this->defined[$name])
+ || !$this->isSupportedDirectCall($parent, count($candidate['closure']->params))
+ ) {
+ $this->invalid[$name] = true;
+ return;
+ }
+
+ $this->candidates[$name]['calls']++;
+ }
+
+ private function isSupportedDirectCall(Expr\FuncCall $call, int $parameterCount): bool
+ {
+ if (count($call->args) !== $parameterCount) {
+ return false;
+ }
+ foreach ($call->args as $argument) {
+ if (!$argument instanceof Node\Arg || $argument->unpack || $argument->name !== null) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/src/Build/PrecompiledHeaderManager.php b/src/Build/PrecompiledHeaderManager.php
index 21549ac6..de403bc9 100644
--- a/src/Build/PrecompiledHeaderManager.php
+++ b/src/Build/PrecompiledHeaderManager.php
@@ -149,7 +149,15 @@ final readonly class PrecompiledHeaderManager
}
sort($files, SORT_STRING);
foreach ($files as $file) {
- hash_update($context, $file . "\0");
+ $metadata = stat($file);
+ if ($metadata === false) {
+ throw new \RuntimeException('Cannot stat precompiled-header dependency: ' . $file);
+ }
+ // Clang records dependency mtimes in a PCH and rejects the PCH even
+ // when a rewritten header still has identical contents. Include
+ // the metadata as well as the content so such SDK refreshes select
+ // a new cache entry before native compilation begins.
+ hash_update($context, $file . "\0" . $metadata['size'] . "\0" . $metadata['mtime'] . "\0");
if (!hash_update_file($context, $file)) {
throw new \RuntimeException('Cannot fingerprint precompiled-header dependency: ' . $file);
}
diff --git a/src/CompilerBase.php b/src/CompilerBase.php
index 3ef2a3a8..4e841d09 100644
--- a/src/CompilerBase.php
+++ b/src/CompilerBase.php
@@ -5184,6 +5184,9 @@ class CompilerBase implements PropertyAccessContext
{
$code = '';
foreach ($localVars as $name => $type) {
+ if (isset($this->context->nativeLocalClosures[$name])) {
+ continue;
+ }
if (isset($this->context->arguments[$name])) {
continue;
}
diff --git a/src/Context/FunctionContext.php b/src/Context/FunctionContext.php
index 0953ecdb..a4e52591 100644
--- a/src/Context/FunctionContext.php
+++ b/src/Context/FunctionContext.php
@@ -72,6 +72,16 @@ class FunctionContext
public array $explicitNativeTypeVars = [];
/** @var array C++ initializers folded into function-scope local declarations. */
public array $localVarInitializers = [];
+ /**
+ * Proven non-escaping local Closure candidates. These are declaration-site
+ * plans only; the generator moves a successfully lowered entry into
+ * nativeLocalClosures when it emits the concrete C++ lambda.
+ *
+ * @var array
+ */
+ public array $localClosureCandidates = [];
+ /** @var array Local variables already emitted as concrete C++ lambdas. */
+ public array $nativeLocalClosures = [];
/** @var array typed-ref local => directly referenced local. */
public array $typedRefBindings = [];
/** @var array typed-ref local => canonical fixed-storage root. */
@@ -137,6 +147,8 @@ class FunctionContext
$this->localVars = [];
$this->explicitNativeTypeVars = [];
$this->localVarInitializers = [];
+ $this->localClosureCandidates = [];
+ $this->nativeLocalClosures = [];
$this->typedRefBindings = [];
$this->typedRefRoots = [];
$this->typedRefAliases = [];
@@ -196,6 +208,8 @@ class FunctionContext
{
$this->localVars = $localVars;
$this->localVarInitializers = [];
+ $this->localClosureCandidates = [];
+ $this->nativeLocalClosures = [];
$this->typedRefBindings = [];
$this->typedRefRoots = [];
$this->typedRefAliases = [];
diff --git a/src/Generator/ClosureGenerator.php b/src/Generator/ClosureGenerator.php
index 9826bf1f..e1f130eb 100644
--- a/src/Generator/ClosureGenerator.php
+++ b/src/Generator/ClosureGenerator.php
@@ -53,6 +53,12 @@ trait ClosureGenerator
}
protected function parseArrowFunction(Expr\ArrowFunction $expr): string
+ {
+ return $this->genClosure($expr, $expr->params, $this->collectArrowFunctionUses($expr));
+ }
+
+ /** @return list */
+ private function collectArrowFunctionUses(Expr\ArrowFunction $expr): array
{
$nodeFinder = new NodeFinder();
$vars = $nodeFinder->findInstanceOf($expr->expr, Variable::class);
@@ -75,9 +81,7 @@ trait ClosureGenerator
}
$uses[$varName] = new Node\ClosureUse($var);
}
- $uses = array_values($uses);
-
- return $this->genClosure($expr, $expr->params, $uses);
+ return array_values($uses);
}
protected function parseClosure(Expr\Closure $expr): string
@@ -85,6 +89,243 @@ trait ClosureGenerator
return $this->genClosure($expr, $expr->params, $expr->uses);
}
+ /**
+ * Lower a proven non-escaping local Closure at its PHP creation site.
+ * Returning null deliberately selects the ordinary Zend Closure path.
+ */
+ protected function parseNativeLocalClosureAssignment(Expr\Assign $assign): ?string
+ {
+ if (!$this->isVarExpr($assign->var) || !is_string($assign->var->name)) {
+ return null;
+ }
+
+ $sourceName = $assign->var->name;
+ $name = $this->parseIdentifier($assign->var);
+ $candidate = $this->context->localClosureCandidates[$sourceName] ?? null;
+ if ($candidate === null || $candidate['assignment'] !== $assign) {
+ return null;
+ }
+
+ $expr = $candidate['closure'];
+ $uses = $expr instanceof Expr\ArrowFunction
+ ? $this->collectArrowFunctionUses($expr)
+ : $expr->uses;
+ $capturePlan = $this->buildNativeLocalClosureCapturePlan($uses);
+ if ($capturePlan === null) {
+ return null;
+ }
+
+ // The local lambda no longer crosses a Zend boundary, but its declared
+ // PHP signature remains observable at each direct call.
+ foreach ($expr->params as $param) {
+ if (!$param->type instanceof Node\Name) {
+ $this->resolveTypeDecl($param->type, self::DECL_TYPE_OF_PARAM);
+ }
+ }
+ if (!$expr->returnType instanceof Node\Name) {
+ $this->resolveTypeDecl($expr->returnType, self::DECL_TYPE_OF_RETURN);
+ }
+
+ $entryContext = $this->context;
+ $entryIndent = $this->indentLevel;
+ $entryInGeneratorBody = $this->inGeneratorBody;
+ $parameters = [];
+ foreach ($expr->params as $param) {
+ $parameters[] = Type::VAR . ' ' . $this->parseIdentifier($param->var);
+ }
+
+ $code = 'auto ' . $name . ' = [' . implode(', ', $capturePlan['cpp']) . ']('
+ . implode(', ', $parameters) . ') mutable -> ' . Type::VAR . ' {' . PHP_EOL;
+
+ try {
+ $this->context = new FunctionContext();
+ $this->context->inClosure = true;
+ $this->inGeneratorBody = false;
+ $this->indentLevel = $entryIndent + 1;
+
+ $returnType = $expr->returnType;
+ $returnTypeName = $returnType instanceof Node\Identifier
+ ? strtolower($returnType->name)
+ : '';
+ if ($returnType !== null && $returnTypeName !== 'void') {
+ $returnTypeInfo = $this->buildTypeCheckFromNode($returnType, true);
+ if (!empty($returnTypeInfo['check'])) {
+ $this->context->closureReturnTypeCheck = $returnTypeInfo['check'];
+ $this->context->closureReturnTypeStr = $returnTypeInfo['typeStr'];
+ }
+ }
+
+ $parameterChecks = '';
+ foreach ($expr->params as $index => $param) {
+ $paramName = $this->parseIdentifier($param->var);
+ $this->addArgument($paramName, Type::VAR);
+ if (CompileTimeAttribute::consume($param, 'Immutable')) {
+ $this->context->immutableVars[$paramName] = true;
+ if ($this->immutableTypeNodeMayBeObject($param->type)) {
+ $this->context->immutableObjectVars[$paramName] = true;
+ }
+ if ($param->type !== null) {
+ [, $parameterClass] = $this->resolveTypeDecl(
+ $param->type,
+ self::DECL_TYPE_OF_PARAM,
+ );
+ if ($parameterClass !== '') {
+ $this->addObject($paramName, $parameterClass);
+ }
+ }
+ }
+ $parameterChecks .= $this->genNativeLocalClosureParamTypeCheck($param, $paramName, $index);
+ }
+
+ foreach ($capturePlan['bindings'] as $binding) {
+ $this->addArgument($binding['name'], $binding['type']);
+ if ($binding['class'] !== '') {
+ $this->addObject($binding['name'], $binding['class']);
+ }
+ if ($binding['immutable']) {
+ $this->context->immutableVars[$binding['name']] = true;
+ if ($binding['immutableObject']) {
+ $this->context->immutableObjectVars[$binding['name']] = true;
+ }
+ }
+ }
+
+ $body = $this->genClosureBody($expr);
+ if ($this->context->needsUserCodeCallableScope) {
+ $body = $this->genUserCodeCallableScopeGuard() . $body;
+ }
+ $code .= $this->genScopeVarDecl() . $parameterChecks . $body;
+ if (!str_ends_with($code, PHP_EOL)) {
+ $code .= PHP_EOL;
+ }
+ $code .= $this->getIndent(0) . '}';
+ } finally {
+ $this->context = $entryContext;
+ $this->indentLevel = $entryIndent;
+ $this->inGeneratorBody = $entryInGeneratorBody;
+ }
+
+ $this->addLocalVar($name, Type::OBJECT);
+ $this->context->nativeLocalClosures[$name] = true;
+ return $code;
+ }
+
+ /**
+ * @param list $uses
+ * @return array{
+ * cpp: list,
+ * bindings: list
+ * }|null
+ */
+ private function buildNativeLocalClosureCapturePlan(array $uses): ?array
+ {
+ $cpp = [];
+ $bindings = [];
+ foreach ($uses as $useItem) {
+ if (!$this->isVarExpr($useItem->var) || !is_string($useItem->var->name)) {
+ return null;
+ }
+ $name = $this->parseIdentifier($useItem->var);
+ if (!$this->hasLocalVar($name)
+ || $this->isNativeObjectVar($name)
+ || $this->isStdContainer($name)
+ ) {
+ return null;
+ }
+
+ $rawType = $this->getRawVarType($name);
+ $valueType = Type::getReferencedType($rawType);
+ if ($useItem->byRef) {
+ $captureType = Type::getReferenceType($valueType);
+ if ($captureType === null) {
+ return null;
+ }
+ $cpp[] = '&' . $name;
+ } else {
+ if ($rawType === Type::REF || !in_array($valueType, [
+ Type::VAR,
+ Type::BOOL,
+ Type::INT,
+ Type::FLOAT,
+ Type::OBJECT,
+ Type::ARRAY,
+ Type::STR,
+ ], true)) {
+ return null;
+ }
+ $captureType = $valueType;
+ $cpp[] = $name . ' = ' . $name;
+ }
+
+ $bindings[] = [
+ 'name' => $name,
+ 'type' => $captureType,
+ 'class' => $valueType === Type::OBJECT ? $this->getDeclaredObjectType($name) : '',
+ 'immutable' => isset($this->context->immutableVars[$name]),
+ 'immutableObject' => isset($this->context->immutableObjectVars[$name]),
+ ];
+ }
+ return ['cpp' => $cpp, 'bindings' => $bindings];
+ }
+
+ private function genNativeLocalClosureParamTypeCheck(Node\Param $param, string $var, int $index): string
+ {
+ if ($param->type === null) {
+ return '';
+ }
+ $typeInfo = $this->buildTypeCheckFromNode($param->type, true);
+ if (empty($typeInfo['check'])) {
+ return '';
+ }
+
+ $argInfo = new ArgInfo();
+ $argInfo->name = $var;
+ $argInfo->phpName = is_string($param->var->name)
+ ? $param->var->name
+ : $this->unescapeVarName($var);
+ $argInfo->type = Type::VAR;
+ $argInfo->typeCheck = $typeInfo['check'];
+ $argInfo->typeStr = $typeInfo['typeStr'];
+ $argInfo->typeNode = $param->type;
+ return $this->genClosureParamCheck($argInfo, $index);
+ }
+
+ protected function parseNativeLocalClosureCall(Expr\FuncCall $expr, string $name): ?string
+ {
+ if (!isset($this->context->nativeLocalClosures[$name])) {
+ return null;
+ }
+
+ $arguments = [];
+ $forceMaterialize = count($expr->args) > 1;
+ foreach ($expr->args as $argument) {
+ $this->assertExprCanBeUsedAsValue($argument->value, 'function argument');
+ if ($this->isVarExpr($argument->value)) {
+ $this->assertStdContainerDoesNotEscapeNativeObjects(
+ $argument,
+ $this->parseIdentifier($argument->value),
+ );
+ }
+ $class = $this->detectClassOfExpr($argument->value);
+ if ($class !== '' && $this->isNativeObjectClass($class)) {
+ $this->fatalError(
+ $argument,
+ 'Native objects cannot cross a local Closure php::Var parameter boundary',
+ );
+ }
+
+ if ($this->isVarExpr($argument->value)
+ && $this->isStdContainer($this->parseIdentifier($argument->value))
+ ) {
+ $value = $this->parseOrderedArg($argument);
+ } else {
+ $value = $this->parseOrderedOperand($argument->value, false, $forceMaterialize);
+ }
+ $arguments[] = $this->materializeCallArgValue($argument->value, $value);
+ }
+ return $name . '(' . implode(', ', $arguments) . ')';
+ }
+
protected function isReturnStmtInLastLine(array $stmts): bool
{
if (count($stmts) === 0) {
diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php
index 3dc784d0..e87ab9f6 100644
--- a/src/Parser/AssignOpTrait.php
+++ b/src/Parser/AssignOpTrait.php
@@ -172,6 +172,11 @@ trait AssignOpTrait
protected function parseAssign(Expr\Assign $v): string
{
+ $nativeClosure = $this->parseNativeLocalClosureAssignment($v);
+ if ($nativeClosure !== null) {
+ return $nativeClosure;
+ }
+
$left = $v->var;
$right = $v->expr;
if ($this->isAssignExpr($right)) {
diff --git a/src/Parser/FunctionCallTrait.php b/src/Parser/FunctionCallTrait.php
index 6e482f68..eb202b43 100644
--- a/src/Parser/FunctionCallTrait.php
+++ b/src/Parser/FunctionCallTrait.php
@@ -83,6 +83,14 @@ trait FunctionCallTrait
return $pythonObjectCall;
}
+ if ($this->isVarExpr($expr->name) && is_string($expr->name->name)) {
+ $localName = $this->parseIdentifier($expr->name);
+ $nativeClosureCall = $this->parseNativeLocalClosureCall($expr, $localName);
+ if ($nativeClosureCall !== null) {
+ return $nativeClosureCall;
+ }
+ }
+
$callableClass = $this->detectClassOfExpr($expr->name);
if ($this->isNativeObjectClass($callableClass)) {
if ($expr->isFirstClassCallable()) {
diff --git a/src/Translator.php b/src/Translator.php
index 1feb84fb..00f1c54b 100644
--- a/src/Translator.php
+++ b/src/Translator.php
@@ -11,6 +11,7 @@ namespace TypePhp;
use Ajaxray\AnsiKit\AnsiTerminal;
use Ajaxray\AnsiKit\Components\Progressbar;
use MJS\TopSort\Implementations\StringSort;
+use TypePhp\Analysis\LocalClosureAnalyzer;
use TypePhp\Analysis\SsaBuilder;
use TypePhp\Backend\CompilerFactory;
use TypePhp\Build\CompileOptions;
@@ -5088,6 +5089,10 @@ CODE;
}
}
+ if ($v->stmts && !$this->class && $this->methodDef === null) {
+ $this->context->localClosureCandidates = (new LocalClosureAnalyzer())->analyze($v->stmts);
+ }
+
$stmts = '';
$this->indentLevel++;
try {
diff --git a/tests/compiler/closure/local-native-closure.phpt b/tests/compiler/closure/local-native-closure.phpt
new file mode 100644
index 00000000..a51da208
--- /dev/null
+++ b/tests/compiler/closure/local-native-closure.phpt
@@ -0,0 +1,68 @@
+--TEST--
+Non-escaping local Closures preserve captures, typed references, checks and argument order
+--FILE--
+ $left . ':' . $right;
+ var_dump($pair($next(), $next()), $counter);
+
+ $typed = static fn (int $value): int => $value;
+ try {
+ $typed(std::any('bad'));
+ } catch (TypeError $error) {
+ echo "parameter type checked\n";
+ }
+
+ $badReturn = static fn (): int => std::any('bad');
+ try {
+ $badReturn();
+ } catch (TypeError $error) {
+ echo "return type checked\n";
+ }
+}
+?>
+--EXPECT--
+int(12)
+int(13)
+int(100)
+int(2)
+string(2) "a!"
+float(2)
+bool(false)
+array(1) {
+ [0]=>
+ int(2)
+}
+string(3) "0:1"
+int(2)
+parameter type checked
+return type checked