diff --git a/docs/ARRAY_DEF.md b/docs/ARRAY_DEF.md new file mode 100644 index 00000000..4c8b9bd1 --- /dev/null +++ b/docs/ARRAY_DEF.md @@ -0,0 +1,37 @@ +# `#[ArrayDef]` compile-time array contracts + +`#[ArrayDef]` attaches key/value type information to a property declared +exactly as `array`. It supports both Zend classes and `#[Native]` classes and +has no runtime metadata or per-read overhead. + +```php +class Index +{ + #[ArrayDef(Type::String)] + public array $names = []; // list + + #[ArrayDef(Type::Int, Type::String)] + public array $labels = []; // map +} +``` + +One argument defines a list value type. Two arguments define a map key type +and value type. Map keys are restricted to `Type::Int` or `Type::String`. +`ClassName::class` is therefore valid only as a list element type or as the +second (value) argument of a map. + +For direct writes whose expression types are known, the compiler either emits +the normal write unchanged or reports a fatal type error. An `any` key/value is +checked with PHPX exact-type helpers at runtime. No coercive `intval()` or +string conversion is performed. + +List writes support `[]`, an existing integer index, and the exact append form +`$object->property[count($object->property)]`. Existing-index writes use +`php::safeIndex()`; negative and out-of-range indexes fail at runtime. Maps do +not support `[]` append writes. + +The contract intentionally applies only to direct element assignment lowered +by TypePHP. Reads and in-place operators are unchanged. Values passed through +dynamic functions, callbacks, Reflection, `eval()`, or other ZendVM escape +paths are outside the contract and have undefined behavior from ArrayDef's +perspective. diff --git a/docs/NATIVE_CLASS_IMPLEMENTATION_AUDIT.md b/docs/NATIVE_CLASS_IMPLEMENTATION_AUDIT.md index 0eb876e9..0c8a6eec 100644 --- a/docs/NATIVE_CLASS_IMPLEMENTATION_AUDIT.md +++ b/docs/NATIVE_CLASS_IMPLEMENTATION_AUDIT.md @@ -76,7 +76,7 @@ | `__destruct` 由 GC 至多执行一次,继承链 derived-to-base | Native finalizer chain | destructor/finalizer/lifecycle 系列 PHPT | 已验证 | | `__invoke` 和 `__toString` 使用确定 Native Call | Native magic method allow-list | `magic-methods.phpt` | 已验证 | | 动态魔术方法、变量属性/方法名不支持 | Native magic/dynamic access deny-list | dynamic magic、variable method/property 负向测试 | 已验证 | -| `toArray/toString/toInt/toFloat/toBool` 要求实体方法和精确返回类型 | Native keyword method resolution | `keyword-conversions.phpt` 及 missing/wrong return 负向测试 | 已验证 | +| `toArray/toString/toInt/toFloat/toBool/toObject` 要求实体方法、零参数和精确返回类型 | Native keyword method resolution | `keyword-conversions.phpt`、`testNativeObjectToObjectKeywordUsesDeclaredNativeMethod` 及签名负向测试 | 已验证 | | `count($obj)` 仅在实现 Countable 时特化 | Native count optimizer | `keyword-conversions.phpt`、count-without-countable 负向测试 | 已验证 | | `ArrayAccess` 直接语法映射到 Native `offset*()` 方法 | Native array access lowering | `array-access.phpt` | 已验证 | | Native `ArrayAccess` 禁止间接修改和引用 | writable-chain/reference validators | ArrayAccess compound/increment/nested/property/reference/coalesce 负向测试 | 已验证 | diff --git a/docs/NATIVE_CLASS_OBJECT.md b/docs/NATIVE_CLASS_OBJECT.md index ea8b1dfa..38c0c8fd 100644 --- a/docs/NATIVE_CLASS_OBJECT.md +++ b/docs/NATIVE_CLASS_OBJECT.md @@ -4,6 +4,8 @@ > 构造/克隆/析构、Trait、Getter/Setter、Property Hook、抽象类、单继承、有限虚分派、 > Interface 编译期契约及项目级 global slot 预发现均已落地。逐项实现证据见 > [NATIVE_CLASS_IMPLEMENTATION_AUDIT.md](NATIVE_CLASS_IMPLEMENTATION_AUDIT.md)。 +> Native Class 与普通 Zend Object、PHPX Box 并存的架构原因见 +> [OBJECT_STORAGE_AND_PASSING_MODELS.md](OBJECT_STORAGE_AND_PASSING_MODELS.md)。 ## 1. 背景 @@ -1206,6 +1208,11 @@ Native 局部变量是一个由 root frame 跟踪的 `native_struct *` 槽。普 Native Class 支持 `toArray()`、`toString()`、`toInt()`、`toFloat()`、`toBool()` 等 TypePHP 关键词转换方法,但不会进入 PHPX 的动态转换 helper。编译器要求 Native Class 实际声明对应的零参数方法,并把调用直接 lowering 为 Native Call。 +`toObject()` 遵循相同规则:Native Class 若声明 `toObject(): object`,关键词调用直接指向 +这个确定的 Native 方法;未声明、声明了参数或返回值不是 object 时均在编译期报错。这里 +的 `toObject()` 是用户定义的数据转换方法,不是把 Native pointer 交给通用 PHPX +`php::toObject()` helper,也不会为 Native Object 建立隐式 Zend carrier。 + 方法返回类型必须与关键词类型完全一致。例如 `toArray(): array`、`toInt(): int`、`toString(): string`;缺少方法、接收参数、按引用返回或返回类型不同均为编译期 FatalError。 对象条件与显式转换是两套语义。`if ($object)`、`!$object`、`$left && $right` 和 @@ -1276,7 +1283,7 @@ $json = json_encode($nativeObject->toArray()); | Property Hook | 支持直接 get/set;间接写入、复合写入、引用、isset/empty 不支持 | | Trait AST 注入 | 支持,注入完成后按普通 Native member 编译 | | `readonly` | 不支持,编译期 FatalError;PHP readonly 是依赖 Zend 属性初始化状态的运行时机制,与 Native 固定裸字段模型不兼容 | -| `toArray()`/`toInt()` 等关键词转换 | 支持,要求 Native Class 声明零参数且返回类型完全一致的方法 | +| `toArray()`/`toInt()`/`toObject()` 等关键词转换 | 支持,要求 Native Class 声明零参数且返回类型完全一致的方法;直接生成 Native Call | | `toString()` / `__toString()` | 支持确定 Native Call;字符串强转、`strval()`、拼接和 `echo` 使用同一规则 | | `count($nativeObject)` | 支持确定 Native Call;要求 Native Class 实现 `Countable`,首版限单参数形式 | | `isset()` / `empty()` | 支持裸指针槽及纯 Native 命名属性链,逐级短路,不进入 ZendVM | diff --git a/docs/OBJECT_STORAGE_AND_PASSING_MODELS.md b/docs/OBJECT_STORAGE_AND_PASSING_MODELS.md new file mode 100644 index 00000000..4ba59f52 --- /dev/null +++ b/docs/OBJECT_STORAGE_AND_PASSING_MODELS.md @@ -0,0 +1,302 @@ +# TypePHP 三套对象存储与传递模型 + +> 状态:当前架构约束。本文解释 TypePHP 为什么同时保留 Zend Object、PHPX Box 和 +> Native Class Object 三套对象式值模型,以及它们各自的所有权、传递方式和边界。 + +## 1. 结论 + +TypePHP 当前存在三套对象存储与传递机制: + +1. 普通 PHP/Zend Object; +2. PHPX Box,包括 Std Container 和高精度类型; +3. `#[Native]` Native Class Object。 + +三者并非同一设计的历史残留,而是分别解决三类互相冲突的问题: + +- Zend Object 保留 PHP 的动态对象语义和 ZendVM 生态兼容性; +- Box 为无法完整写进 PHP 类型声明的 C++ 类型提供不透明 Zend value 载体; +- Native Class Object 为静态可知的业务对象提供接近 C/C++ 的固定布局、裸指针调用和 + tracing GC。 + +任何一种机制都不能在不损失另一种机制核心能力的前提下替代其余两种。当前设计明确接受 +三套模型长期共存,不以“统一对象表示”为目标。 + +## 2. 总览 + +| 维度 | Zend Object | PHPX Box | Native Class Object | +| --- | --- | --- | --- | +| 典型值 | 普通 PHP class 实例 | Std Container、BigInt、BigFloat、Decimal | `#[Native] class` 实例 | +| 主要表示 | `zend_object` / zval | `zend_resource` + `php::Box *` | Native Heap 中的 C++ struct + 裸指针 | +| 类型身份 | `zend_class_entry *` | Box C++ 动态类型、`type_info`/类型 ID | 编译期 Native class,descriptor 保存动态类型 | +| 生命周期 | Zend 引用计数 + Zend 循环 GC | Zend resource 引用计数调用 Box destructor | Wren 风格精确、非移动 mark-sweep GC | +| 参数传递 | `php::Object` / `php::Var`,复制句柄并调整 RC | `php::Var` 携带 resource;热路径提取具体 C++ 引用 | 具体 `NativeClass *` 按值传递,不调整 RC | +| 属性/方法访问 | Zend handlers、动态查找或已缓存 Native Call | 编译器根据具体 Box 类型生成操作 | 固定偏移字段访问和确定的 `php_*` Native Call | +| 动态 PHP 互操作 | 完整 | 作为不透明 resource 有限互操作 | 不可进入 ZendVM value 边界 | +| 循环图处理 | Zend GC 可扫描 Zend object graph | Zend GC 不扫描 Box 内部 C++ 对象图 | Native descriptor 精确 trace Native pointer graph | +| 核心目标 | PHP 兼容性 | 携带 C++ 泛型/扩展值 | 极致静态性能 | + +## 3. 普通 PHP/Zend Object + +### 3.1 存储 + +普通 class 注册到 ZendVM,实例由 `zend_object` 表示。TypePHP 通过 `php::Object`、 +`php::Variant`/`php::Var` 等 PHPX RAII 类型持有对应 zval。 + +对象具有 Zend 的 class entry、属性表、对象 handlers 和方法元数据。根据编译期信息, +TypePHP 可以把部分访问优化为确定的 Native Call,但对象身份和生命周期仍属于 ZendVM。 + +### 3.2 传递和生命周期 + +PHP 对象赋值和参数传递复制对象句柄,不复制对象实体,并遵循 Zend 引用计数。对象图中的 +循环引用由 Zend GC 处理。对象可以自然进入: + +- PHP array 和普通对象属性; +- `mixed`/`object` 变量; +- Closure、Generator、Fiber 和动态调用; +- Reflection、序列化和扩展函数; +- ZendVM 执行的 PHP 代码。 + +### 3.3 必须保留的原因 + +只有 Zend Object 能完整承载 PHP 的运行时对象语义。用 Box 替代会丢失 class entry、对象 +handlers、可见性、Reflection 和动态分派;用 Native Object 替代则会失去 ZendVM 可见性, +并迫使所有动态行为退化为编译期限制。 + +普通 PHP class 因此始终使用 Zend Object。编译器可以优化调用,但不能改变其对象模型。 + +## 4. PHPX Box + +### 4.1 存储 + +`php::Box` 是由 PHPX 管理的 C++ 多态基类。Box 指针注册为 Zend resource,并由 +`php::Var` 携带: + +```text +zval(IS_RESOURCE) + -> zend_resource + -> php::Box* + -> concrete C++ value +``` + +Zend resource 的析构回调最终调用 `Box::destroy()`。Box 因而可以经过普通 zval/Variant +调用边界,同时隐藏 Zend 无法表达的具体 C++ 类型。 + +当前主要使用者包括: + +- `StdContainerBox>`; +- `StdContainerBox>`; +- `StdContainerBox`; +- BigInt、BigFloat、Decimal 等高精度值。 + +### 4.2 Std Container 的热路径 + +Std Container 局部变量具有两层表示: + +```cpp +php::Var values = php::Var(new php::StdContainerBox(type_id)); +auto &values_ref = values.toBox>()->container; +``` + +`php::Var` 负责生命周期和必要的边界传递,具体容器引用用于后续元素访问,避免每次操作都 +重复提取 Box。容器的 key/value/长度等泛型信息由编译器和具体 C++ 模板类型共同保存。 + +Std Container 跨 TypePHP 函数传递时,PHP 函数签名无法表达以下 C++ 类型信息: + +```text +std::vector +std::vector +std::map +``` + +PHP 参数最多只能声明一个非泛型类名或伪类型,不能同时携带容器种类、key 类型、value +类型、数组维度和长度。当前使用 `UnsafePtr`/`std::unsafe_cast()` 加编译器类型 ID 校验, +而不是把所有组合生成为 PHP class。 + +理论上可以增加参数和返回值注解描述泛型,但这要求每个声明、调用、返回、属性和传播点 +都维护额外元数据,PHP Reflection 仍无法完整表达它。当前不引入这套独立泛型 ABI。 + +### 4.3 Box 的边界 + +Box 是不透明值载体,不是通用对象系统: + +- Zend GC 只看见 resource,不会扫描 Box 内部保存的 C++ 引用; +- Box 不提供 PHP class 的方法表、属性表、继承和 Reflection; +- 通过 `dynamic_cast`、类型 ID 或专用 helper 恢复具体类型; +- 不应使用 Box 构建需要跨 Zend/Box 双向追踪的任意循环对象图; +- Std Container 的可用位置和逃逸路径继续受编译器限制。 + +Box 适合数值、容器和其他边界明确的扩展值。它不适合代替具有任意字段引用关系的 Native +业务对象。 + +### 4.4 必须保留的原因 + +Std Container 的泛型类型无法由 PHP 函数参数完整表达;高精度值又需要作为 `php::Var` +参与现有运算和调用。Box 同时提供: + +- 可放进 zval 的稳定载体; +- C++ 具体类型的运行时恢复; +- Zend request 生命周期内的自动析构; +- 不为每一种模板实例注册一套 PHP class 的轻量实现。 + +Zend Object 无法直接表达 C++ 模板实例;Native 裸指针则无法安全穿过 `php::Var` 和动态 +ZendVM 边界。因此 Box 仍有独立存在的必要。 + +## 5. Native Class Object + +### 5.1 存储 + +`#[Native]` class 不注册 Zend class,不生成 Zend object handlers,也没有 zval 表示。每个 +对象是 Native Heap 中的固定布局 C++ struct,TypePHP 局部变量、参数、返回值和字段保存 +具体 Native 指针: + +```cpp +php_app__point *point; +``` + +方法继续使用 TypePHP 的自由函数 ABI: + +```cpp +php::Float php_app__point__length(php_app__point &this_); +``` + +普通调用只传递一个指针值。不会创建 zval、注册 resource、执行引用计数或通过 +`zend_call_function()`。 + +### 5.2 生命周期 + +Native Object 使用 PHPX 中独立的 Wren 风格精确、非移动、stop-the-world mark-sweep GC: + +- Native 局部变量、参数、返回临时值和 global/static slot 进入精确 root frame; +- Native 对象 descriptor 负责 trace Native pointer 字段; +- Std Container 保存 Native pointer 时注册专用 container root frame; +- 循环引用由 tracing GC 回收,不依赖引用计数降为零; +- 16-byte GC header 保存收集器所需的最小状态; +- `__destruct()` 由 Native finalization 执行,而不是由 Zend object destructor 执行。 + +Native 指针赋值不增加引用计数,也不需要 write barrier。固定字段直接按 C++ 偏移访问。 + +### 5.3 传递边界 + +Native Object 参数和返回值必须显式声明具体 Native class,或受支持的 nullable 具体类型: + +```php +function distance(Point $left, Point $right): float; +function findPoint(): ?Point; +``` + +这使编译器可以把签名直接生成为 `Point *`。Native Object 不支持: + +- 传给 PHP/ZendVM 函数、Closure 或动态 callable; +- 保存到 PHP array、普通 Zend Object 属性或 `mixed`; +- 自动转换为 `php::Object`、`php::Var` 或 Interface value; +- 依靠运行时 class name 恢复类型; +- 使用通用 PHPX `toObject()` helper 完成装箱或拆箱。Native Class 可以声明自己的 + `toObject(): object` 方法;关键词调用会直接解析为该 Native Call,并不提供通用 bridge。 + +需要进入 PHP API 时,用户必须显式转换数据,例如先调用 Native `toArray(): array`,再把 +结果传给 `json_encode()`。该转换产生的是数据副本,不保留 Native 对象身份。 + +### 5.4 必须保留的原因 + +Native Class 的目标是接近 C/C++ 的热路径性能: + +- 一个机器字的对象句柄; +- 固定字段布局; +- 不进行 Zend RC 增减; +- 不分配 `zend_object` 或 `zend_resource` carrier; +- 确定符号 Native Call; +- 可由 C++ 编译器内联和去虚化。 + +若改用 Box,每个 Native Object 都需要 resource/zval 封装、RC 管理和具体类型恢复,而且 +Zend GC 无法扫描 Box 内部 Native 指针图;这既降低性能,也不能正确替代 Native tracing +GC。若改用自定义 `zend_object`,虽然能够接入 Zend GC 和动态边界,但对象 header、RC、 +handlers 和访问路径都会改变 Native Class 的性能定位。 + +因此 Native Class 继续使用独立 Native Heap 和裸指针 ABI。 + +## 6. 为什么不能统一 + +### 6.1 不能全部改为 Zend Object + +这样可以统一动态语义,却会让 Std Container 泛型实例和 Native Class 都承担 Zend object +header、RC、handlers、class registration 与动态访问成本。Native Class 将不再接近 C/C++, +Std Container 也需要为大量模板组合设计运行时 class 体系。 + +### 6.2 不能全部改为 Box + +Box 能通过 zval 携带 C++ 值,但 Zend GC 不理解 Box 内部对象图。它不能替代普通 PHP +Object 的动态元数据,也不能在保持 Native 循环回收能力的同时提供裸指针热路径。 + +### 6.3 不能全部改为 Native pointer + +Native pointer 要求完整静态类型。普通 PHP 对象需要 Reflection、动态属性、动态 callable +和 Zend 扩展互操作;Std Container 的完整泛型类型又无法写入 PHP 参数签名。把这些值都 +改为裸指针会产生无法静态证明安全的类型擦除,并可能导致错误指针转换和崩溃。 + +### 6.4 不增加自动桥接 + +三套模型之间不进行隐式对象身份转换。自动装箱/拆箱会隐藏分配、复制、RC 和 GC root +变化,也会使编译器边界不再可靠。 + +允许的转换必须具有明确语义: + +- Std Container 转 PHP array:复制容器数据; +- Native Object 的 `toArray()` 等实体方法:由用户定义并显式复制数据; +- 高精度类型的显式标量转换:产生新的 PHP 标量值; +- 普通 Zend Object 不会自动变成 Native Object。 + +## 7. 编译器实现约束 + +后续修改必须保持以下不变量: + +1. 先根据静态类型确定对象模型,再选择代码生成路径;不得在运行时猜测三者之一。 +2. Native Object 不得因通用 fallback 被包装成 `php::Var` 或传入 ZendVM。 +3. Box 的具体类型恢复必须校验 resource 类型和 concrete C++ 类型/类型 ID。 +4. Zend Object 优化不得改变 Zend 对象身份、生命周期或动态可见性。 +5. 三种模型的参数 ABI 不得混用:`php::Object`、Box-bearing `php::Var`、`NativeClass *` + 分别代表不同所有权和类型约束。 +6. 跨模型转换必须显式,并在文档和生成代码中体现分配或复制成本。 +7. 若一个新特性需要牺牲所有 Native Class 热路径来获得少量动态兼容,应优先在编译期禁止。 +8. 若一种新的 C++ 泛型类型需要穿过 Zend value 边界,应优先评估 Box,而不是扩大 Native + Object 的动态边界。 +9. 若一个值需要完整 PHP 对象语义,应使用 Zend Object,不能把 Box 当作简化的 PHP class。 + +## 8. 代码位置 + +主要实现入口: + +```text +普通 Zend Object + compiler/src/Parser/* + phpx/include/phpx.h Object / Variant / Zend API wrappers + +PHPX Box 与 Std Container + phpx/include/phpx.h Box / StdContainerBox + phpx/src/core/base.cc Box resource registration and destructor + compiler/src/Parser/StdContainerTrait.php + +Native Class Object + compiler/src/NativeClass/ + compiler/src/Transform/NativeClassAttributeLowering.php + phpx/include/phpx_native_gc.h + phpx/src/core/native_gc.cc + phpx/thirdparty/wren-gc/ +``` + +详细规则分别见 [STD_CONTAINERS.md](STD_CONTAINERS.md)、 +[NATIVE_CLASS_OBJECT.md](NATIVE_CLASS_OBJECT.md) 和 +[NATIVE_CLASS_IMPLEMENTATION_AUDIT.md](NATIVE_CLASS_IMPLEMENTATION_AUDIT.md)。 + +## 9. 当前决策 + +当前阶段不实施以下重构: + +- 不移除 Wren GC; +- 不把 Native Object 改为 Box 或自定义 Zend Object; +- 不给 Native Object 增加通用 `toObject()` 动态恢复机制;Native Class 自定义的 + `toObject(): object` 仍是普通的确定 Native Call; +- 不把 Std Container 改为无法跨签名表达类型的裸指针 ABI; +- 不尝试用单一统一 wrapper 覆盖三种对象模型。 + +未来只有在 PHP 语言层能够稳定表达泛型参数、或者有经过 benchmark 和完整 GC 正确性验证 +的新 ABI 时,才重新评估这些边界。在此之前,三套机制的共存是有意的架构选择。 diff --git a/docs/README.md b/docs/README.md index 15ad121b..c5e654aa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,8 +11,10 @@ - [快速入门](QUICKSTART.md):最小编译流程。 - [编译期函数](COMPILE_TIME_FUNCTIONS.md):`any()`、`refval()`、`objval()`、`expected()`、`unexpected()` 和关键词方法。 - [原生类型](NATIVE_TYPES.md)、[高精度类型](HIGH_PRECISION_TYPES.md)、[Std 容器](STD_CONTAINERS.md)。 +- [三套对象存储与传递模型](OBJECT_STORAGE_AND_PASSING_MODELS.md):Zend Object、PHPX Box 与 Native Class Object 的职责、ABI 和不可替代边界。 - [通用与扩展方法](UNIVERSAL_METHODS.md)、[Generator](YIELD_GENERATOR.md)。 - [`#[Immutable]` 编译期只读契约](IMMUTABLE.md):方法、参数、别名、调用边界与动态逃逸规则。 +- [`#[ArrayDef]` 数组属性契约](ARRAY_DEF.md):List/Map 元数据、直接写入检查与动态逃逸边界。 - [类继承](CLASS_INHERITANCE.md)、[混合 C++/PHP](MIXED_CPP_PHP.md)。 ## 架构与维护 @@ -23,6 +25,7 @@ - [重建 PHPX WASM 静态库](PHPX_WASM_BUILD.md):增量重编 `libphpx.a`、数值依赖重建与完整 SDK 重建边界。 - [核心重构计划](REFACTORING_PLAN.md) - [作用域管理设计](SCOPE_MANAGEMENT.md):`CallableScope`、`UserCodeScopeGuard` 与 `FakeScopeGuard` 的职责和使用边界。 +- [Native Class Object 设计](NATIVE_CLASS_OBJECT.md) 与 [实现验收矩阵](NATIVE_CLASS_IMPLEMENTATION_AUDIT.md)。 - [PHP 8.4 Property Hook 集成设计](PROPERTY_HOOKS.md):编译期 lowering、Zend Hook 元数据、对象内省及 PHPX ABI 边界。 - [Interface Property Hook 实现方案](INTERFACE_PROPERTY_HOOKS.md):接口属性契约、编译期方差检查及 PHP 8.4 抽象 Hook 元数据。 - [构建速度研究](AOT_BUILD_SPEED_RESEARCH.md) diff --git a/docs/STD_CONTAINERS.md b/docs/STD_CONTAINERS.md index 9133e4d1..50fd3592 100644 --- a/docs/STD_CONTAINERS.md +++ b/docs/STD_CONTAINERS.md @@ -1,5 +1,9 @@ # Swoole AOT 强类型高性能容器,数组访问性能提升 10 倍 +> Std Container 使用 PHPX Box 保存具体 C++ 模板实例。它与普通 Zend Object、Native +> Class Object 的存储和传递边界见 +> [OBJECT_STORAGE_AND_PASSING_MODELS.md](OBJECT_STORAGE_AND_PASSING_MODELS.md)。 + Swoole AOT 编译器为 PHP 提供了一组 `std` 强类型容器,用于在 AOT 编译场景下替代部分性能敏感路径中的 PHP Array。它们保留接近 PHP 的访问语法,同时让编译器获得明确的元素类型、键类型和容器结构,从而生成更直接、更低开销的 C++ 代码。 ## PHP Array 的问题 diff --git a/phpunit/code/array-def-class-map-key.php b/phpunit/code/array-def-class-map-key.php new file mode 100644 index 00000000..747b683c --- /dev/null +++ b/phpunit/code/array-def-class-map-key.php @@ -0,0 +1,7 @@ +value[] = 'bad'; +} diff --git a/phpunit/code/array-def-native-class-value.php b/phpunit/code/array-def-native-class-value.php new file mode 100644 index 00000000..91b7be6b --- /dev/null +++ b/phpunit/code/array-def-native-class-value.php @@ -0,0 +1,8 @@ +users[] = new ArrayDefOtherUser(); +} diff --git a/phpunit/code/array-def-static-key-mismatch.php b/phpunit/code/array-def-static-key-mismatch.php new file mode 100644 index 00000000..9cc44dd1 --- /dev/null +++ b/phpunit/code/array-def-static-key-mismatch.php @@ -0,0 +1,10 @@ +value['bad'] = 'value'; +} diff --git a/phpunit/code/array-def-static-value-mismatch.php b/phpunit/code/array-def-static-value-mismatch.php new file mode 100644 index 00000000..1c434e85 --- /dev/null +++ b/phpunit/code/array-def-static-value-mismatch.php @@ -0,0 +1,11 @@ +value[] = 123; +} diff --git a/phpunit/code/array-def-std-container-value.php b/phpunit/code/array-def-std-container-value.php new file mode 100644 index 00000000..208fd257 --- /dev/null +++ b/phpunit/code/array-def-std-container-value.php @@ -0,0 +1,11 @@ +values[] = $values; +} diff --git a/phpunit/code/array-def-too-many-arguments.php b/phpunit/code/array-def-too-many-arguments.php new file mode 100644 index 00000000..153b6ed4 --- /dev/null +++ b/phpunit/code/array-def-too-many-arguments.php @@ -0,0 +1,6 @@ +toObject(stdClass::class); +} diff --git a/phpunit/code/native-class-to-object-return-type.php b/phpunit/code/native-class-to-object-return-type.php new file mode 100644 index 00000000..30f869f2 --- /dev/null +++ b/phpunit/code/native-class-to-object-return-type.php @@ -0,0 +1,16 @@ +toObject(); +} diff --git a/phpunit/code/native-class-to-object.php b/phpunit/code/native-class-to-object.php new file mode 100644 index 00000000..45e23fbb --- /dev/null +++ b/phpunit/code/native-class-to-object.php @@ -0,0 +1,16 @@ +toObject(); +} diff --git a/phpunit/src/ArrayDefTest.php b/phpunit/src/ArrayDefTest.php new file mode 100644 index 00000000..e8b1f89c --- /dev/null +++ b/phpunit/src/ArrayDefTest.php @@ -0,0 +1,19 @@ +exec('ArrayDef can only be applied to properties declared as array', 'array-def-non-array-property.php'); + $this->exec('ArrayDef expects one or two type arguments', 'array-def-no-arguments.php'); + $this->exec('ArrayDef expects one or two type arguments', 'array-def-too-many-arguments.php'); + $this->exec('ArrayDef map keys must use Type::Int or Type::String', 'array-def-invalid-map-key.php'); + $this->exec('ArrayDef map keys must use Type::Int or Type::String', 'array-def-class-map-key.php'); + $this->exec('ArrayDef map properties do not support append writes', 'array-def-map-append.php'); + $this->exec('expects key of type int, string given', 'array-def-static-key-mismatch.php'); + $this->exec('expects value of type string, int given', 'array-def-static-value-mismatch.php'); + $this->exec('expects value of type ArrayDefExpectedUser, ArrayDefOtherUser given', 'array-def-static-class-mismatch.php'); + $this->exec('Native class types cannot be used in ArrayDef', 'array-def-native-class-value.php'); + $this->exec('Std Container values cannot be stored in ArrayDef properties', 'array-def-std-container-value.php'); + } +} diff --git a/phpunit/src/CompileTimeAttributeRegistryTest.php b/phpunit/src/CompileTimeAttributeRegistryTest.php index ad9c3821..414b47d2 100644 --- a/phpunit/src/CompileTimeAttributeRegistryTest.php +++ b/phpunit/src/CompileTimeAttributeRegistryTest.php @@ -10,6 +10,7 @@ final class CompileTimeAttributeRegistryTest extends TestCase $expected = [ 'Native', 'MethodsFor', 'NoExport', 'WasmExport', 'Getter', 'Setter', 'With', 'Printer', 'Arrayable', 'NotNull', 'NotEmpty', 'Validate', 'Override', 'MustUse', 'Immutable', 'Hot', 'Cold', 'Constructor', + 'ArrayDef', ]; $this->assertSame($expected, CompileTimeAttributeRegistry::names()); @@ -25,6 +26,10 @@ final class CompileTimeAttributeRegistryTest extends TestCase $this->assertNotContains('Native', CompileTimeAttributeRegistry::names(true)); $this->assertContains('Getter', CompileTimeAttributeRegistry::names(true)); $this->assertContains('Override', CompileTimeAttributeRegistry::names(true)); + $this->assertSame( + ['Native', 'MethodsFor', 'NoExport', 'WasmExport', 'ArrayDef'], + CompileTimeAttributeRegistry::namesForPhase(CompileTimeAttributeRegistry::PHASE_PREPROCESS), + ); $this->assertSame( ['Override', 'MustUse', 'Immutable', 'Hot', 'Cold'], CompileTimeAttributeRegistry::namesForPhase(CompileTimeAttributeRegistry::PHASE_ENTER), diff --git a/phpunit/src/LocalVariableInitializerTest.php b/phpunit/src/LocalVariableInitializerTest.php new file mode 100644 index 00000000..6453ff03 --- /dev/null +++ b/phpunit/src/LocalVariableInitializerTest.php @@ -0,0 +1,38 @@ +addFiles([$source]); + $compiler->prepareFile($source); + $generated = $compiler->convertFile($source); + $code = file_get_contents($generated); + + self::assertIsString($code); + self::assertStringContainsString('php::Var integer = 42L;', $code); + self::assertStringContainsString('php::Var negative = -7L;', $code); + self::assertStringContainsString('php::Var floating = 1.25;', $code); + self::assertStringContainsString('php::Var boolean = true;', $code); + self::assertMatchesRegularExpression('/php::Str string = _literal_strings\[\d+\];/', $code); + self::assertStringContainsString('php::Var nullValue = php::null;', $code); + + self::assertStringContainsString('php::Var nested;', $code); + self::assertStringContainsString('nested = 9L;', $code); + self::assertStringContainsString('php::Var computed;', $code); + self::assertStringContainsString('computed = ((40L) + (2L));', $code); + + $afterDeclaration = substr( + $code, + strpos($code, 'php::Var integer = 42L;') + strlen('php::Var integer = 42L;'), + ); + self::assertStringNotContainsString('integer = 42L;', $afterDeclaration); + } +} diff --git a/phpunit/src/NativeClass/NativeClassValidationTest.php b/phpunit/src/NativeClass/NativeClassValidationTest.php index a06f9e0e..c7f7a730 100644 --- a/phpunit/src/NativeClass/NativeClassValidationTest.php +++ b/phpunit/src/NativeClass/NativeClassValidationTest.php @@ -445,6 +445,25 @@ final class NativeClassValidationTest extends \BaseTest $this->compile('native-class-reference-method.php'); } + public function testNativeObjectToObjectKeywordUsesDeclaredNativeMethod(): void + { + $this->compile('native-class-to-object.php'); + } + + public function testRejectsNativeObjectToObjectKeywordWithParameters(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Native conversion method `InvalidNativeToObjectParameters::toObject()` must not accept arguments'); + $this->compile('native-class-to-object-parameters.php'); + } + + public function testRejectsNativeObjectToObjectKeywordWithWrongReturnType(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Native conversion method `InvalidNativeToObjectReturn::toObject()` must return exactly `object`'); + $this->compile('native-class-to-object-return-type.php'); + } + public function testRejectsNativeObjectReferenceFunction(): void { $this->expectException(TestError::class); diff --git a/src/ArrayDef/ArrayDefSupportTrait.php b/src/ArrayDef/ArrayDefSupportTrait.php new file mode 100644 index 00000000..8868f01c --- /dev/null +++ b/src/ArrayDef/ArrayDefSupportTrait.php @@ -0,0 +1,291 @@ +type instanceof Node\Identifier + || strtolower($property->type->toString()) !== 'array' + ) { + $this->fatalError($property, 'ArrayDef can only be applied to properties declared as array'); + } + + $count = count($attribute->args); + if ($count < 1 || $count > 2) { + $this->fatalError($attribute, 'ArrayDef expects one or two type arguments'); + } + + $types = []; + foreach ($attribute->args as $arg) { + if ($arg->name !== null || !$arg->value instanceof Expr\ClassConstFetch) { + $this->fatalError($arg, 'ArrayDef arguments must be Type::* or ClassName::class constants'); + } + $class = $arg->value->class; + $constant = $arg->value->name; + if (!$class instanceof Node\Name || !$constant instanceof Node\Identifier) { + $this->fatalError($arg, 'ArrayDef arguments must be Type::* or ClassName::class constants'); + } + $resolved = $class->getAttribute('resolvedName') ?? $class; + $resolvedClass = ltrim($resolved->toString(), '\\'); + if (strcasecmp($constant->toString(), 'class') === 0) { + if ($this->isNativeObjectClass($resolvedClass)) { + $this->fatalError($arg, 'Native class types cannot be used in ArrayDef'); + } + $types[] = $resolvedClass; + continue; + } + if (strcasecmp($resolvedClass, 'Type') !== 0) { + $this->fatalError($arg, 'ArrayDef arguments must be Type::* or ClassName::class constants'); + } + $types[] = $this->resolveArrayDefType($constant->toString(), $arg); + } + + if ($count === 1) { + return new ArrayDefinition(null, $types[0]); + } + if (!in_array($types[0], [Type::INT, Type::STR], true)) { + $this->fatalError($attribute, 'ArrayDef map keys must use Type::Int or Type::String'); + } + return new ArrayDefinition($types[0], $types[1]); + } + + private function resolveArrayDefType(string $name, NodeAbstract $errorNode): string + { + $type = match (strtolower($name)) { + 'int' => Type::INT, + 'float' => Type::FLOAT, + 'bool' => Type::BOOL, + 'string' => Type::STR, + 'array' => Type::ARRAY, + 'object' => Type::OBJECT, + 'any' => Type::VAR, + 'stream' => Type::STREAM, + 'bigint' => Type::BIGINT, + 'bigfloat' => Type::BIGFLOAT, + 'decimal' => Type::DECIMAL, + default => null, + }; + if ($type === null) { + $this->fatalError($errorNode, "Unsupported ArrayDef type Type::{$name}"); + } + return $type; + } + + protected function prepareArrayDefDirectWrite( + Expr\ArrayDimFetch $left, + Expr $right, + string $value, + ): ?ArrayDefWritePlan + { + $def = $this->getNativePropertyDef($left->var); + $arrayDef = $def?->arrayDef; + if ($arrayDef === null) { + return null; + } + + $value = $this->validateArrayDefWriteValue($left->var, $right, $value, $arrayDef->valueType, 'value'); + if ($left->dim === null) { + if (!$arrayDef->isList()) { + $this->fatalError($left, 'ArrayDef map properties do not support append writes'); + } + return new ArrayDefWritePlan(true, null, $value); + } + + if ($arrayDef->isList() && $this->isArrayDefCountAppend($left->dim, $left->var)) { + return new ArrayDefWritePlan(true, null, $value); + } + + $expectedKey = $arrayDef->keyType ?? Type::INT; + $key = $this->parseExprAsValue($left->dim); + $key = $this->validateArrayDefWriteValue($left->var, $left->dim, $key, $expectedKey, 'key'); + if ($arrayDef->isList()) { + $array = $this->parseWritableIdentifier($left->var); + $key = 'php::safeIndex(' . $key . ', ' . $array . '.length())'; + } + + return new ArrayDefWritePlan(false, $key, $value); + } + + private function validateArrayDefWriteValue( + NodeAbstract $property, + Expr $expr, + string $code, + string $expected, + string $part, + ): string + { + $actual = $this->detectTypeOfExpr($expr); + $stdContainerValue = $this->isVarExpr($expr) + && $this->isStdContainer($this->parseIdentifier($expr)); + if ($part === 'value' && ($stdContainerValue || $this->isStdContainerType($actual))) { + $this->fatalError($expr, 'Std Container values cannot be stored in ArrayDef properties'); + } + if ($expected === Type::VAR) { + return $code; + } + + if ($this->isArrayDefClassType($expected)) { + $actualClass = $this->detectClassOfExpr($expr); + if ($actualClass !== '') { + if (!$this->isObjectClassStaticallyAssignableTo($actualClass, $expected)) { + $this->fatalError( + $expr, + 'ArrayDef property ' . $this->getObjectPropertyTypeCheckDisplayName($property) + . ' expects ' . $part . ' of type ' . $expected + . ', ' . $actualClass . ' given', + ); + } + return $code; + } + + if ($actual !== Type::VAR && $actual !== Type::OBJECT) { + $this->fatalError( + $expr, + 'ArrayDef property ' . $this->getObjectPropertyTypeCheckDisplayName($property) + . ' expects ' . $part . ' of type ' . $expected + . ', ' . $this->arrayDefTypeName($actual) . ' given', + ); + } + return 'php::toObjectExact(' + . $code . ', ' . $this->getClassEntryPtr($expected) . ', ' + . $this->genCharPtr( + $this->getObjectPropertyTypeCheckDisplayName($property) . ' ArrayDef ' . $part, + true, + ) + . ')'; + } + + if ($actual !== Type::VAR) { + if ($actual !== $expected) { + $this->fatalError( + $expr, + 'ArrayDef property ' . $this->getObjectPropertyTypeCheckDisplayName($property) + . ' expects ' . $part . ' of type ' . $this->arrayDefTypeName($expected) + . ', ' . $this->arrayDefTypeName($actual) . ' given', + ); + } + return $code; + } + + $display = $this->genCharPtr( + $this->getObjectPropertyTypeCheckDisplayName($property) . ' ArrayDef ' . $part, + true, + ); + $helper = match ($expected) { + Type::INT => 'php::toIntExact', + Type::FLOAT => 'php::toFloatExact', + Type::BOOL => 'php::toBoolExact', + Type::STR => 'php::toStringExact', + Type::ARRAY => 'php::toArrayExact', + Type::OBJECT => 'php::toObjectExact', + Type::STREAM => 'php::toStreamExact', + Type::BIGINT => 'php::toBoxExact', + Type::BIGFLOAT => 'php::toBoxExact', + Type::DECIMAL => 'php::toBoxExact', + default => null, + }; + if ($helper === null) { + return $code; + } + $boxType = in_array($expected, [Type::BIGINT, Type::BIGFLOAT, Type::DECIMAL], true) + ? ', ' . $this->genCharPtr($this->arrayDefTypeName($expected), true) + : ''; + return $helper . '(' . $code . ', ' . $display . $boxType . ')'; + } + + private function arrayDefTypeName(string $type): string + { + return match ($type) { + Type::INT => 'int', + Type::FLOAT => 'float', + Type::BOOL => 'bool', + Type::STR => 'string', + Type::ARRAY => 'array', + Type::OBJECT => 'object', + Type::STREAM => 'stream', + Type::BIGINT => 'BigInt', + Type::BIGFLOAT => 'BigFloat', + Type::DECIMAL => 'Decimal', + Type::VAR => 'any', + default => $type, + }; + } + + private function isArrayDefClassType(string $type): bool + { + return !in_array($type, [ + Type::INT, + Type::FLOAT, + Type::BOOL, + Type::STR, + Type::ARRAY, + Type::OBJECT, + Type::VAR, + Type::STREAM, + Type::BIGINT, + Type::BIGFLOAT, + Type::DECIMAL, + ], true); + } + + private function isArrayDefCountAppend(Expr $dim, Expr $property): bool + { + if (!$dim instanceof Expr\FuncCall + || !$dim->name instanceof Node\Name + || strtolower(ltrim($dim->name->toString(), '\\')) !== 'count' + || count($dim->args) !== 1 + || !$dim->args[0] instanceof Node\Arg + ) { + return false; + } + return $this->isSameArrayDefProperty($dim->args[0]->value, $property); + } + + private function isSameArrayDefProperty(Expr $left, Expr $right): bool + { + if ($left instanceof Expr\StaticPropertyFetch && $right instanceof Expr\StaticPropertyFetch) { + return $left->class instanceof Node\Name + && $right->class instanceof Node\Name + && $left->name instanceof Node\Identifier + && $right->name instanceof Node\Identifier + && strcasecmp($left->class->toString(), $right->class->toString()) === 0 + && strcasecmp($left->name->toString(), $right->name->toString()) === 0; + } + if (!$left instanceof Expr\PropertyFetch || !$right instanceof Expr\PropertyFetch + || !$left->name instanceof Node\Identifier || !$right->name instanceof Node\Identifier + || strcasecmp($left->name->toString(), $right->name->toString()) !== 0 + ) { + return false; + } + return $left->var instanceof Expr\Variable + && $right->var instanceof Expr\Variable + && is_string($left->var->name) + && $left->var->name === $right->var->name; + } +} diff --git a/src/ArrayDef/ArrayDefWritePlan.php b/src/ArrayDef/ArrayDefWritePlan.php new file mode 100644 index 00000000..80b14fad --- /dev/null +++ b/src/ArrayDef/ArrayDefWritePlan.php @@ -0,0 +1,19 @@ +keyType === null; + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 68436f65..c0aacaf4 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -85,6 +85,7 @@ use TypePhp\TypeSystem\NativeTypeCompatibilityTrait; use TypePhp\NativeClass\NativeClassSupportTrait; use TypePhp\NativeClass\NativeGlobalTypeResolver; use TypePhp\Immutable\ImmutableSupportTrait; +use TypePhp\ArrayDef\ArrayDefSupportTrait; use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\Node\ArrayItem; @@ -107,6 +108,7 @@ class CompilerBase implements PropertyAccessContext use NativeTypeCompatibilityTrait; use NativeClassSupportTrait; use ImmutableSupportTrait; + use ArrayDefSupportTrait; use NativeBuildConfigurationTrait; use PythonModuleTrait; use DeclarationSymbolTrait; @@ -1707,7 +1709,8 @@ class CompilerBase implements PropertyAccessContext } elseif ($this->inGeneratorBody && $v->expr instanceof Expr\YieldFrom) { $result = $this->parseYieldFromStmt($v->expr); } else { - $result = $this->parseExpr($v->expr) . ';'; + $expression = $this->parseExpr($v->expr); + $result = $expression === '' ? '' : $expression . ';'; } break; case 'Stmt_Echo': @@ -5007,7 +5010,9 @@ class CompilerBase implements PropertyAccessContext $code .= Type::VAR . ' ' . $name . ';'; } else { $code .= $type . ' ' . $name; - if ($this->isNativeObjectVar($name)) { + if (array_key_exists($name, $this->context->localVarInitializers)) { + $code .= ' = ' . $this->context->localVarInitializers[$name]; + } elseif ($this->isNativeObjectVar($name)) { $code .= ' = nullptr'; } elseif ($type === Type::INT or $type === Type::FLOAT or $type === Type::BOOL) { $code .= ' = 0'; diff --git a/src/Context/FunctionContext.php b/src/Context/FunctionContext.php index 721a884f..36796ce6 100644 --- a/src/Context/FunctionContext.php +++ b/src/Context/FunctionContext.php @@ -59,6 +59,8 @@ class FunctionContext */ public array $stdContainers = []; public array $localVars = []; + /** @var array C++ initializers folded into function-scope local declarations. */ + public array $localVarInitializers = []; public array $staticVars = []; public array $globalVars = []; @@ -104,6 +106,7 @@ class FunctionContext public function __construct() { $this->localVars = []; + $this->localVarInitializers = []; $this->staticVars = []; $this->arguments = []; $this->immutableVars = []; @@ -154,6 +157,7 @@ class FunctionContext ): void { $this->localVars = $localVars; + $this->localVarInitializers = []; $this->tmpVarIndex = $tmpVarIndex; $this->declaredObjects = $declaredObjects; $this->nativeObjects = $nativeObjects; diff --git a/src/Entity/PropertyDef.php b/src/Entity/PropertyDef.php index 13b3a69f..9daa7ee9 100644 --- a/src/Entity/PropertyDef.php +++ b/src/Entity/PropertyDef.php @@ -9,6 +9,7 @@ namespace TypePhp\Entity; use PhpParser\Modifiers; +use TypePhp\ArrayDef\ArrayDefinition; class PropertyDef { @@ -17,6 +18,7 @@ class PropertyDef public int $flags; public ?string $default = null; public ?ArrayInitPlan $arrayInitPlan = null; + public ?ArrayDefinition $arrayDef = null; public bool $nullable = false; /** The declared type is TypePHP's unconstrained, reference-capable `any` type. */ public bool $explicitAny = false; diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index a088b23c..220b6371 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -152,7 +152,48 @@ trait AssignOpTrait return $optimized; } } - return $this->parseAssignFinally($left, $right); + return $this->parseAssignFinally( + $left, + $right, + $this->canFoldLocalLiteralIntoDeclaration($v), + ); + } + + /** + * A pure literal assigned to a new function-top-level local can initialize + * the already-hoisted C++ declaration directly. Keep arrays and compound + * constant expressions on the ordinary path: parsing them may create + * temporaries whose declaration order must remain explicit. + */ + private function canFoldLocalLiteralIntoDeclaration(Expr\Assign $assign): bool + { + if (!$assign->getAttribute(self::ATTR_STATEMENT_EXPRESSION, false) + || $this->context->scopeLevel !== 1 + || !$this->isVarExpr($assign->var) + || !is_string($assign->var->name) + ) { + return false; + } + $name = $this->parseWritableIdentifier($assign->var); + if ($name === 'this_' || $this->hasVar($name)) { + return false; + } + return $this->isDeclarationLiteral($assign->expr); + } + + private function isDeclarationLiteral(Expr $expr): bool + { + if ($expr instanceof Node\Scalar\Int_ + || $expr instanceof Node\Scalar\Float_ + || $expr instanceof Node\Scalar\String_ + ) { + return true; + } + if ($expr instanceof Expr\ConstFetch) { + return in_array(strtolower($expr->name->toString()), ['true', 'false', 'null'], true); + } + return ($expr instanceof Expr\UnaryPlus || $expr instanceof Expr\UnaryMinus) + && ($expr->expr instanceof Node\Scalar\Int_ || $expr->expr instanceof Node\Scalar\Float_); } private function parseAssignToMultiReturn(Expr\List_ $left, Expr $right): ?string @@ -234,7 +275,11 @@ trait AssignOpTrait return $code . '}'; } - protected function parseAssignFinally(Expr $left, Expr $right): string + protected function parseAssignFinally( + Expr $left, + Expr $right, + bool $foldIntoDeclaration = false, + ): string { $this->assertImmutableMutationTarget($left); $this->recordImmutableAlias($left, $right); @@ -589,6 +634,13 @@ trait AssignOpTrait return $this->parseAssignArrayDim($left, $right); } elseif ($this->isArrayDimFetch($left) and $this->isPropertyFetch($left->var)) { return $this->parseAssignPropertyArrayDim($left, $right); + } elseif ($this->isArrayDimFetch($left) and $this->isStaticPropertyFetch($left->var)) { + // Keep ordinary static-array writes on their established lowering + // path. Only ArrayDef needs the specialized key/value plan below. + $this->preparePropertyWriteTarget($left->var); + if ($this->getNativePropertyDef($left->var)?->arrayDef !== null) { + return $this->parseAssignStaticPropertyArrayDim($left, $right); + } } if ($propertyWriteTarget !== null) { @@ -629,11 +681,14 @@ trait AssignOpTrait : $rightExprType; return $var . ' = ' . $this->convertNativePropertyWriteExpr($propertyDef->type, $effectiveRightType, $rightExpr); } - if ($finalVarType === Type::VAR) { - return $var . ' = ' . $rightExpr; - } else { - return $var . ' = ' . $this->convertExprType($rightExpr, $leftExprType, $rightExprType); + $assignedExpr = $finalVarType === Type::VAR + ? $rightExpr + : $this->convertExprType($rightExpr, $leftExprType, $rightExprType); + if ($foldIntoDeclaration) { + $this->context->localVarInitializers[$var] = $assignedExpr; + return ''; } + return $var . ' = ' . $assignedExpr; } protected function parseAssignPropertyHook( @@ -1213,18 +1268,39 @@ trait AssignOpTrait $propertyWriteTarget = $this->preparePropertyWriteTarget($left->var); $code = ''; $value = $this->parseExprAsValue($right); + $arrayDefWrite = $this->prepareArrayDefDirectWrite($left, $right, $value); + if ($arrayDefWrite !== null) { + $value = $arrayDefWrite->value; + } $tmp = $this->genTmpVarName(); $this->addLocalVar($tmp, Type::VAR); - if ($left->dim === null) { + if ($left->dim === null || ($arrayDefWrite !== null && $arrayDefWrite->append)) { return $code . '((' . $tmp . ' = ' . $value . ', ' . $this->emitDynamicPropertyFetchAppendArray($left->var, $tmp, $propertyWriteTarget) . '), ' . $tmp . ')'; } - $dim = $this->parseIdentifier($left->dim); + $dim = $arrayDefWrite?->key ?? $this->parseIdentifier($left->dim); return $code . '((' . $tmp . ' = ' . $value . ', ' . $this->emitDynamicPropertyFetchUpdateArray($left->var, $dim, $tmp, $propertyWriteTarget) . '), ' . $tmp . ')'; } + protected function parseAssignStaticPropertyArrayDim(Expr\ArrayDimFetch $left, Expr $right): string + { + $value = $this->parseExprAsValue($right); + $arrayDefWrite = $this->prepareArrayDefDirectWrite($left, $right, $value); + $array = $this->parseWritableIdentifier($left->var); + + $tmp = $this->genTmpVarName(); + $this->addLocalVar($tmp, Type::VAR); + $value = $arrayDefWrite?->value ?? $value; + + if ($left->dim === null || ($arrayDefWrite !== null && $arrayDefWrite->append)) { + return '((' . $tmp . ' = ' . $value . ', ' . $array . '.newItem() = ' . $tmp . '), ' . $tmp . ')'; + } + $dim = $arrayDefWrite?->key ?? $this->parseIdentifier($left->dim); + return '((' . $tmp . ' = ' . $value . ', ' . $array . '.item(' . $dim . ', true) = ' . $tmp . '), ' . $tmp . ')'; + } + protected function parseAssignOpCoalesce(Expr\AssignOp\Coalesce $expr): string { $this->assertImmutableMutationTarget($expr->var); diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 9e22650e..ef72cdd5 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -612,7 +612,8 @@ class Preprocessor extends CompilerBase // Promoted property defaults belong to the constructor parameter, // not to the property default table. The property itself must stay // uninitialized until __construct assigns it. - $this->addClassProperty($phpName, $param->flags, $param->type, null, $nullable, $param, true); + $promotedProperty = $this->addClassProperty($phpName, $param->flags, $param->type, null, $nullable, $param, true); + $promotedProperty->arrayDef = $this->parseArrayDefinition($param); } if ($param->variadic) { if ($i !== $last) { @@ -1645,6 +1646,7 @@ class Preprocessor extends CompilerBase protected function parseClassPropertyDef(Node\Stmt\Property $v): void { + $arrayDef = $this->parseArrayDefinition($v); if ($this->classDef->nativeObject) { if ($v->type === null) { $this->fatalError($v, 'Native class properties must declare a type'); @@ -1669,6 +1671,7 @@ class Preprocessor extends CompilerBase foreach ($v->props as $prop) { $propName = $this->parseIdentifier($prop->name); $propDef = $this->addClassProperty($propName, $v->flags, $v->type, $prop->default, $nullable, $v); + $propDef->arrayDef = $arrayDef; if ($this->classDef->nativeObject && $this->isNativeObjectForbiddenPropertyType($propDef)) { $message = $propDef->type === Type::BOX ? 'Native class properties cannot use Box types' diff --git a/src/Transform/CompileTimeAttributeRegistry.php b/src/Transform/CompileTimeAttributeRegistry.php index 5885b325..4ba6bf13 100644 --- a/src/Transform/CompileTimeAttributeRegistry.php +++ b/src/Transform/CompileTimeAttributeRegistry.php @@ -25,6 +25,7 @@ final class CompileTimeAttributeRegistry public const ARGUMENTS_FIELDS = 'fields'; public const ARGUMENTS_VALIDATE = 'validate'; public const ARGUMENTS_WASM_EXPORT = 'wasm_export'; + public const ARGUMENTS_ARRAY_DEF = 'array_def'; public const PHASE_PREPROCESS = 'preprocess'; public const PHASE_ENTER = 'enter'; @@ -93,6 +94,7 @@ final class CompileTimeAttributeRegistry $add('Hot', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'Hot can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER, true, ['Cold']); $add('Cold', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'Cold can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER, true, ['Hot']); $add('Constructor', [self::TARGET_DECLARED_PROPERTY], 'Constructor can only be applied to instance properties', self::ARGUMENTS_NONE, self::PHASE_CLASS_LEAVE); + $add('ArrayDef', [self::TARGET_PROPERTY], 'ArrayDef can only be applied to properties', self::ARGUMENTS_ARRAY_DEF, self::PHASE_PREPROCESS); return $definitions; } diff --git a/src/Translator.php b/src/Translator.php index fa7bbd50..ac88f05f 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -4986,7 +4986,8 @@ CODE; && $existing->type === $incoming->type && $existing->class === $incoming->class && $existing->nullable === $incoming->nullable - && $existing->default === $incoming->default; + && $existing->default === $incoming->default + && $existing->arrayDef == $incoming->arrayDef; } private function resolveLateBoundClass(ClassDef $usingClassDef, string $keyword): ?string diff --git a/src/polyfills.php b/src/polyfills.php index a673ce4f..0fe1695b 100644 --- a/src/polyfills.php +++ b/src/polyfills.php @@ -115,6 +115,14 @@ final readonly class Constructor { } +#[Attribute(Attribute::TARGET_PROPERTY)] +final readonly class ArrayDef +{ + public function __construct(string $keyOrValueType, ?string $valueType = null) + { + } +} + /** * Public compile-time type symbols shared by MethodsFor providers and std containers. * This root class is deliberately distinct from the compiler-internal TypePhp\Type. diff --git a/tests/compiler/array-def/class-values.phpt b/tests/compiler/array-def/class-values.phpt new file mode 100644 index 00000000..3901c9a3 --- /dev/null +++ b/tests/compiler/array-def/class-values.phpt @@ -0,0 +1,76 @@ +--TEST-- +ArrayDef supports class value types, subclasses, aliases and dynamic checks +--FILE-- +list[] = $value; + } + + function putMap(UserCollection $collection, any $key, any $value): void + { + $collection->map[$key] = $value; + } +} + +namespace { + function main(): void + { + $collection = new Demo\UserCollection(); + $collection->list[] = new App\User('user'); + $collection->list[] = new App\Admin('admin'); + $collection->map['owner'] = new App\Admin('owner'); + Demo\putList($collection, new App\Admin('dynamic-list')); + Demo\putMap($collection, 'dynamic', new App\User('dynamic-map')); + + foreach ($collection->list as $user) { + echo $user->name, "\n"; + } + foreach ($collection->map as $key => $user) { + echo $key, '=', $user->name, "\n"; + } + + try { + Demo\putList($collection, new App\Other()); + } catch (TypeError $error) { + echo "list class checked\n"; + } + try { + Demo\putMap($collection, 'bad', new stdClass()); + } catch (TypeError $error) { + echo "map class checked\n"; + } + } +} +?> +--EXPECT-- +user +admin +dynamic-list +owner=owner +dynamic=dynamic-map +list class checked +map class checked diff --git a/tests/compiler/array-def/compound-types.phpt b/tests/compiler/array-def/compound-types.phpt new file mode 100644 index 00000000..c095c5a8 --- /dev/null +++ b/tests/compiler/array-def/compound-types.phpt @@ -0,0 +1,84 @@ +--TEST-- +ArrayDef checks dynamic compound and high-precision value types +--FILE-- +arrays[] = $value; + } elseif ($slot === 1) { + $box->objects[] = $value; + } elseif ($slot === 2) { + $box->bigInts[] = $value; + } elseif ($slot === 3) { + $box->bigFloats[] = $value; + } else { + $box->decimals[] = $value; + } +} + +function main(): void +{ + $box = new ArrayDefCompoundBox(); + putCompound($box, 0, [1, 2]); + putCompound($box, 1, new stdClass()); + putCompound($box, 2, std::bigInt(3)); + putCompound($box, 3, std::bigFloat('4')); + putCompound($box, 4, std::decimal('5')); + + var_dump($box->arrays[0]); + var_dump($box->objects[0] instanceof stdClass); + echo std::bigInt($box->bigInts[0])->toString(), "\n"; + echo std::bigFloat($box->bigFloats[0])->toString(), "\n"; + echo std::decimal($box->decimals[0])->toString(), "\n"; + + try { + putCompound($box, 0, 'not-array'); + } catch (TypeError $error) { + echo "array checked\n"; + } + try { + putCompound($box, 1, []); + } catch (TypeError $error) { + echo "object checked\n"; + } + try { + putCompound($box, 2, std::decimal('6')); + } catch (TypeError $error) { + echo "BigInt checked\n"; + } +} +?> +--EXPECT-- +array(2) { + [0]=> + int(1) + [1]=> + int(2) +} +bool(true) +3 +4 +5 +array checked +object checked +BigInt checked diff --git a/tests/compiler/array-def/zend-and-native.phpt b/tests/compiler/array-def/zend-and-native.phpt new file mode 100644 index 00000000..047b293a --- /dev/null +++ b/tests/compiler/array-def/zend-and-native.phpt @@ -0,0 +1,131 @@ +--TEST-- +ArrayDef enforces direct list and map writes for Zend and Native classes +--FILE-- +names[$key] = $value; +} + +function writeDynamicMap(NativeArrayDefBox $box, any $key, any $value): void +{ + $box->counters[$key] = $value; +} + +function main(): void +{ + $zend = new ZendArrayDefBox(); + $zend->names[] = 'first'; + $zend->names[count($zend->names)] = 'second'; + $zend->names[0] = 'changed'; + $zend->labels[10] = 'ten'; + ZendArrayDefBox::$staticCounters['writes'] = 1; + + $promoted = new PromotedArrayDefBox(); + $promoted->values[] = 13; + + $native = new NativeArrayDefBox(); + $native->values[] = 7; + $native->values[count($native->values)] = 8; + $native->values[1] = 9; + $native->counters['ok'] = 11; + + writeDynamicList($zend, 1, 'dynamic'); + writeDynamicMap($native, 'dynamic', 12); + + var_dump($zend->names, $zend->labels, ZendArrayDefBox::$staticCounters, $promoted->values, $native->values, $native->counters); + + try { + writeDynamicList($zend, '1', 'bad-key'); + } catch (TypeError $error) { + echo "list key type checked\n"; + } + try { + writeDynamicList($zend, 0, 123); + } catch (TypeError $error) { + echo "list value type checked\n"; + } + try { + writeDynamicMap($native, 1, 12); + } catch (TypeError $error) { + echo "map key type checked\n"; + } + try { + writeDynamicMap($native, 'bad', '12'); + } catch (TypeError $error) { + echo "map value type checked\n"; + } + try { + writeDynamicList($zend, 20, 'out'); + } catch (Error $error) { + echo "list bounds checked\n"; + } +} +?> +--EXPECT-- +array(2) { + [0]=> + string(7) "changed" + [1]=> + string(7) "dynamic" +} +array(1) { + [10]=> + string(3) "ten" +} +array(1) { + ["writes"]=> + int(1) +} +array(1) { + [0]=> + int(13) +} +array(2) { + [0]=> + int(7) + [1]=> + int(9) +} +array(2) { + ["ok"]=> + int(11) + ["dynamic"]=> + int(12) +} +list key type checked +list value type checked +map key type checked +map value type checked +list bounds checked diff --git a/tests/compiler/basic/local-literal-declaration-initializer.phpt b/tests/compiler/basic/local-literal-declaration-initializer.phpt new file mode 100644 index 00000000..e67b25cb --- /dev/null +++ b/tests/compiler/basic/local-literal-declaration-initializer.phpt @@ -0,0 +1,34 @@ +--TEST-- +Top-level literal local assignments preserve PHP values +--FILE-- + +--EXPECT-- +array(6) { + [0]=> + int(42) + [1]=> + int(-7) + [2]=> + float(1.25) + [3]=> + bool(true) + [4]=> + string(5) "hello" + [5]=> + NULL +}