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