From 8657560cad43a59106cf92c702992bcfcf8a3370 Mon Sep 17 00:00:00 2001 From: Guillaume Loulier Date: Sun, 13 Sep 2026 17:27:12 +0200 Subject: [PATCH 1/2] feat(parser): support PHP's (object) cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #836. `(object)` was rejected during parsing, so `(object) ['name' => 'Laravel']` failed with `Expected ']'` before the cast was ever considered. The conversion is implemented as an elephc-PHP prelude rather than a runtime helper: PHP's rules are a key walk plus dynamic property writes, both of which already exist as ordinary elephc-PHP, so every supported target gets the cast with no per-target assembly — the same reason `var_export` is a prelude. PHP's semantics, reproduced exactly: - an array becomes a stdClass whose property names are the array's keys rendered as strings, so `(object) ['x', 'y']` has the properties `0` and `1`; - `null` becomes an empty stdClass; - every other non-object value becomes a stdClass carrying it under the literal `scalar` property; - an object is returned UNCHANGED — `(object) $o === $o` holds and no copy is made. Two helpers keep the cast's static type precise. `__elephc_cast_object` is declared `: stdClass` and is what `lower_cast` calls when the source cannot be an object, so `(object) ['a' => 1]` types as `stdClass` and property reads stay on the nominal path; only a `mixed`/union source needs the `: mixed` `__elephc_cast_object_dynamic`, whose identity arm can return any class. An object source is lowered to the value itself, with no call at all. The source is lowered ONCE into a synthetic local and the helper call then names that local — the rewrite `ref_place_args` already uses — so the ordinary user-call path owns argument lowering, the return type, and owned-temporary release, and a source with a side effect cannot run twice. The cast has no call syntax, so the reachability walk records both helpers when it sees an object cast; without that the prelude was pruned and the lowered call resolved to nothing. Detection of the cast itself rides on `opcache_prelude::detect`'s single exhaustive walk (`SymbolKind::ObjectCast`) so no second traversal can drift out of step with the AST, and the prelude stays pay-for-use. Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr --- README.md | 2 +- docs/php/types.md | 9 + src/ir_lower/expr/call_arg_coercion.rs | 2 +- src/ir_lower/expr/ternary_cast.rs | 56 +++++ src/lib.rs | 2 + src/main.rs | 1 + src/object_cast_prelude.rs | 189 ++++++++++++++++ src/opcache_prelude/detect.rs | 22 +- src/optimize/fold/casts.rs | 2 + .../reachability/usage/expressions.rs | 17 +- src/parser/ast/expr.rs | 1 + src/parser/expr/calls.rs | 1 + src/php_profile/sensitivity.rs | 3 +- src/pipeline.rs | 9 + src/synthetic_class/print.rs | 1 + src/synthetic_class/transcribe.rs | 1 + src/types/checker/inference/expr/basic.rs | 17 ++ src/types/checker/loop_storage.rs | 2 +- tests/codegen/casts_and_constants.rs | 2 + .../casts_and_constants/object_cast.rs | 201 ++++++++++++++++++ tests/codegen/support/compiler.rs | 2 + tests/parser_tests/expressions/basics.rs | 16 ++ 22 files changed, 548 insertions(+), 10 deletions(-) create mode 100644 src/object_cast_prelude.rs create mode 100644 tests/codegen/casts_and_constants/object_cast.rs diff --git a/README.md b/README.md index 7b0a274a0d..4f4e5deadb 100644 --- a/README.md +++ b/README.md @@ -487,7 +487,7 @@ The full list of supported constructs, operators, and control structures is in t
Show the construct highlights -- **OOP**: classes, abstract/final classes, typed/final/static properties and methods, PHP-style static property redeclarations, direct static array property writes, constructor property promotion, interfaces, `instanceof`, traits, enums, PHP 8 declaration attributes, limited attribute reflection (`ReflectionClass`/`ReflectionMethod`/`ReflectionProperty::getAttributes()`, `ReflectionAttribute::newInstance()`), `readonly`, static/instance methods, case-insensitive class/interface/trait and method lookup, `self::`/`parent::`/`static::`, `::class` reflection (including `$object::class` on object expressions, returning the receiver's runtime class), class constants including PHP 8.3 typed class constants (exposed via `ReflectionClassConstant::hasType()`/`getType()`), `new self()` / `new static()` / `new parent()`, magic methods (`__toString`, `__get`, `__set`, `__isset`, `__unset`, `__call`, `__invoke`, `__clone`, `__destruct`), `clone`, `get_object_vars()` and `(array)` casts on objects +- **OOP**: classes, abstract/final classes, typed/final/static properties and methods, PHP-style static property redeclarations, direct static array property writes, constructor property promotion, interfaces, `instanceof`, traits, enums, PHP 8 declaration attributes, limited attribute reflection (`ReflectionClass`/`ReflectionMethod`/`ReflectionProperty::getAttributes()`, `ReflectionAttribute::newInstance()`), `readonly`, static/instance methods, case-insensitive class/interface/trait and method lookup, `self::`/`parent::`/`static::`, `::class` reflection (including `$object::class` on object expressions, returning the receiver's runtime class), class constants including PHP 8.3 typed class constants (exposed via `ReflectionClassConstant::hasType()`/`getType()`), `new self()` / `new static()` / `new parent()`, magic methods (`__toString`, `__get`, `__set`, `__isset`, `__unset`, `__call`, `__invoke`, `__clone`, `__destruct`), `clone`, `get_object_vars()`, `(array)` casts on objects, and `(object)` casts (array keys become properties, `null` becomes an empty `stdClass`, a scalar lands on `scalar`, and an object source is returned unchanged) - **Functions**: case-insensitive user and built-in function calls, default parameters, variadic/spread, pass by reference, named arguments, global variables, static locals, first-class callables, closures, arrow functions, static closures (`static function () { }`, `static fn () => ...`) - **Generators**: generator functions and closures, `yield`, key/value yields, `yield from`, `Generator::send()`, `throw()`, `getReturn()`, and `foreach` over `Iterator` / `IteratorAggregate` - **Fibers**: `Fiber`, `FiberError`, `Fiber::suspend()`, `Fiber::getCurrent()`, `start()`, `resume()`, `throw()`, `getReturn()`, state predicates, closure captures, guarded native stacks, and target-aware context switching on the three executable/release hosts (macOS ARM64, Linux ARM64, and Linux x86_64); the iOS compile targets are library-only and do not run Fibers diff --git a/docs/php/types.md b/docs/php/types.md index 69f965e664..1877ce2cea 100644 --- a/docs/php/types.md +++ b/docs/php/types.md @@ -178,8 +178,17 @@ $b = (bool)0; // false $a = (array)42; // [42] $o = (array)$obj; // property name => value hash, with PHP's visibility-mangled keys $m = (array)$mixed; // dispatches on the runtime tag: arrays pass through, scalars wrap, objects project +$p = (object)['k' => 1]; // stdClass with a `k` property +$q = (object)42; // stdClass with a `scalar` property ``` +`(object)` converts an array to a `stdClass` whose property names are the +array's keys rendered as strings (so `(object)['x', 'y']` has the properties +`0` and `1`, reachable as `->{'0'}`), `null` to an empty `stdClass`, and every +other non-object value to a `stdClass` carrying it in a single `scalar` +property. An object source is returned **unchanged** — `(object)$obj === $obj` +— rather than copied. + `(array)` on an object projects all of its properties into a string-keyed hash using PHP's exact key mangling — `x` for a public property, `"\0*\0y"` for a protected one, `"\0Class\0z"` for a private one — including the diff --git a/src/ir_lower/expr/call_arg_coercion.rs b/src/ir_lower/expr/call_arg_coercion.rs index a66fbd4728..162c578384 100644 --- a/src/ir_lower/expr/call_arg_coercion.rs +++ b/src/ir_lower/expr/call_arg_coercion.rs @@ -83,7 +83,7 @@ fn apply_scalar_param_cast( CastType::String => coerce_to_string_at_span(ctx, value, span), CastType::Bool => lower_truthy_bool(ctx, value, span), // `param_binding::scalar_param_cast` only ever reports the two total scalar casts. - CastType::Int | CastType::Float | CastType::Array => value, + CastType::Int | CastType::Float | CastType::Array | CastType::Object => value, } } diff --git a/src/ir_lower/expr/ternary_cast.rs b/src/ir_lower/expr/ternary_cast.rs index 7634362b46..5206a30cbd 100644 --- a/src/ir_lower/expr/ternary_cast.rs +++ b/src/ir_lower/expr/ternary_cast.rs @@ -60,6 +60,9 @@ pub(super) fn lower_ternary( /// Lowers a cast expression. pub(super) fn lower_cast(ctx: &mut LoweringContext<'_, '_>, target: &CastType, inner: &Expr, expr: &Expr) -> LoweredValue { + if matches!(target, CastType::Object) { + return lower_object_cast(ctx, inner, expr); + } let value = lower_expr(ctx, inner); // Keep the original producer visible for a no-op string cast. Wrapping an // owned string temporary in `Cast(Str)` would hide its ownership from the @@ -87,6 +90,44 @@ pub(super) fn lower_cast(ctx: &mut LoweringContext<'_, '_>, target: &CastType, i result } +/// Lowers PHP's `(object)` cast. +/// +/// An object source is returned UNCHANGED — PHP's `(object)` is the identity on an object, +/// so no copy is made and `(object) $o === $o` holds. Every other source is converted by the +/// elephc-PHP helpers `object_cast_prelude` injects, which is what keeps the conversion +/// (array keys become property names, `null` becomes an empty stdClass, a scalar becomes a +/// `scalar` property) correct on every supported target with no per-target assembly. +/// +/// The source is lowered ONCE into a synthetic local, and the helper call then names that +/// local — the same rewrite `ref_place_args` uses — so the ordinary user-call path handles +/// argument lowering, the return type, and owned-temporary release, and the source's side +/// effects happen exactly once. +fn lower_object_cast( + ctx: &mut LoweringContext<'_, '_>, + inner: &Expr, + expr: &Expr, +) -> LoweredValue { + let value = lower_expr(ctx, inner); + let source_type = ctx.builder.value_php_type(value.value); + if matches!(source_type.codegen_repr(), PhpType::Object(_)) { + return value; + } + let helper = if matches!( + source_type.codegen_repr(), + PhpType::Mixed | PhpType::Union(_) + ) { + crate::object_cast_prelude::DYNAMIC_CAST_HELPER + } else { + crate::object_cast_prelude::CAST_HELPER + }; + let local_type = normalize_value_php_type(source_type); + let temp = ctx.declare_synthetic_php_local(local_type.clone()); + ctx.store_local(&temp, value, local_type, Some(inner.span)); + let argument = Expr::new(ExprKind::Variable(temp), inner.span); + let name = Name::from(helper.to_string()); + lower_function_call(ctx, &name, std::slice::from_ref(&argument), expr) +} + /// Releases an owning temporary when a scalar coercion cannot alias its source storage. pub(super) fn release_coerced_source_if_owned( ctx: &mut LoweringContext<'_, '_>, @@ -145,5 +186,20 @@ pub(super) fn cast_php_type(target: &CastType, source_type: &PhpType) -> PhpType PhpType::Mixed | PhpType::Union(_) ) => PhpType::Mixed, CastType::Array => PhpType::Array(Box::new(PhpType::Mixed)), + // Mirrors the checker's `(object)` arms in + // `types::checker::inference::expr::basic`: identity on an object, `mixed` for a + // runtime-typed source that may already hold an unrelated class, stdClass otherwise. + CastType::Object if matches!(source_type.codegen_repr(), PhpType::Object(_)) => { + source_type.clone() + } + CastType::Object + if matches!( + source_type.codegen_repr(), + PhpType::Mixed | PhpType::Union(_) + ) => + { + PhpType::Mixed + } + CastType::Object => PhpType::Object("stdClass".to_string()), } } diff --git a/src/lib.rs b/src/lib.rs index 8d788effa5..800b5db796 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,6 +65,8 @@ pub(crate) mod numeric_string; pub mod opcache; /// `opcache_get_configuration()` standard-library prelude injection. pub mod opcache_prelude; +/// PHP `(object)` cast standard-library prelude injection. +pub mod object_cast_prelude; /// Optimizer passes. pub mod optimize; /// Parser for PHP syntax. diff --git a/src/main.rs b/src/main.rs index 51b46f2c79..fcc82a3bee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,7 @@ mod name_resolver; mod native_deps; mod names; mod numeric_string; +mod object_cast_prelude; mod opcache; mod opcache_prelude; mod optimize; diff --git a/src/object_cast_prelude.rs b/src/object_cast_prelude.rs new file mode 100644 index 0000000000..088811744f --- /dev/null +++ b/src/object_cast_prelude.rs @@ -0,0 +1,189 @@ +//! Purpose: +//! Injects the two elephc-PHP helpers that back PHP's `(object)` cast: +//! `__elephc_cast_object` (the statically non-object source) and +//! `__elephc_cast_object_dynamic` (a runtime-typed source that may already hold an object). +//! +//! Called from: +//! - `crate::pipeline::compile()` and the codegen test harness via `inject_if_used`, before +//! name resolution, so `ir_lower::expr::lower_cast` can lower a `(object)` cast to an +//! ordinary call to the injected declaration. +//! +//! Key details: +//! - Implemented as a prelude rather than a runtime helper because PHP's conversion is a +//! key walk plus dynamic property writes — both already exist as ordinary elephc-PHP, so +//! every supported target gets the cast with no per-target assembly (the same reason +//! `var_export_prelude` is a prelude). +//! - PHP's rules, reproduced exactly: an array becomes a stdClass whose property names are +//! the array's keys rendered as strings; `null` becomes an empty stdClass; every other +//! non-object value becomes a stdClass with a single `scalar` property; an object is +//! returned UNCHANGED (same instance, not a copy). +//! - The split into two helpers is what keeps the cast's static type precise. +//! `__elephc_cast_object` is declared `: stdClass` and is what `lower_cast` calls when the +//! source cannot be an object, so `(object) ['a' => 1]` types as `stdClass` and property +//! reads stay on the nominal path. Only a `mixed`/union source needs the `: mixed` +//! `__elephc_cast_object_dynamic`, because the object it returns unchanged may be of any +//! class. +//! - Pay-for-use: injected only when the program spells an object cast anywhere, detected +//! through the shared exhaustive walk in `opcache_prelude::detect` rather than a second +//! traversal of its own. + +use crate::names::Name; +use crate::parser::ast::{BinOp, CastType, Program, TypeExpr}; +use crate::opcache_prelude::detect::{self, Symbol, SymbolKind}; +use crate::synthetic_class::{ + e_assign, e_binop, e_call, e_cast, e_dyn_prop, e_null, e_var, function, + internal_declarations, s_assign, s_expr, s_foreach, s_if, s_prop_assign, s_return, t_mixed, +}; + +/// The PHP property name a scalar, bool, float or resource source is stored under. +/// +/// php-src uses the literal `scalar` for every non-array, non-null, non-object value, so +/// `((object) 42)->scalar` is `42`. The name is not configurable and programs read it back. +const SCALAR_PROPERTY: &str = "scalar"; + +/// Builds `__elephc_cast_object($value): stdClass` — PHP's `(object)` conversion for a +/// source that is statically known not to be an object. +/// +/// The array arm renders each key with `(string)`, which is what makes an integer-keyed +/// array's properties the numeric-string names PHP produces (`((object) [0 => 'a'])->{'0'}`). +fn cast_object_decl() -> crate::parser::ast::Stmt { + function("__elephc_cast_object") + .param("value", t_mixed()) + .returns(TypeExpr::Named(Name::from("stdClass".to_string()))) + .body(vec![ + s_assign("object", crate::synthetic_class::e_new("stdClass", vec![])), + s_if( + e_call("is_array", vec![e_var("value")]), + vec![ + s_foreach( + e_var("value"), + Some("key"), + "element", + vec![ + s_assign("name", e_cast(CastType::String, e_var("key"))), + s_expr(e_assign( + e_dyn_prop(e_var("object"), e_var("name")), + e_var("element"), + )), + ], + ), + s_return(e_var("object")), + ], + vec![], + None, + ), + s_if( + e_binop(e_var("value"), BinOp::StrictEq, e_null()), + vec![s_return(e_var("object"))], + vec![], + None, + ), + s_prop_assign(e_var("object"), SCALAR_PROPERTY, e_var("value")), + s_return(e_var("object")), + ]) + .build() +} + +/// Builds `__elephc_cast_object_dynamic($value): mixed` — the runtime-typed entry point. +/// +/// PHP's `(object)` is the IDENTITY on an object: the same instance comes back, so a later +/// `===` against the source still holds and a mutation through either name is visible +/// through the other. That arm is why this helper returns `mixed` rather than `stdClass` — +/// the instance it hands back can be of any class. +fn cast_object_dynamic_decl() -> crate::parser::ast::Stmt { + function("__elephc_cast_object_dynamic") + .param("value", t_mixed()) + .returns(t_mixed()) + .body(vec![ + s_if( + e_call("is_object", vec![e_var("value")]), + vec![s_return(e_var("value"))], + vec![], + None, + ), + s_return(e_call("__elephc_cast_object", vec![e_var("value")])), + ]) + .build() +} + +/// Builds both object-cast helpers. +pub(crate) fn object_cast_declarations() -> Program { + internal_declarations(|| vec![cast_object_decl(), cast_object_dynamic_decl()]) +} + +/// Returns whether the program spells a `(object)` cast anywhere. +/// +/// Rides on `opcache_prelude::detect`'s single exhaustive walk (see +/// [`SymbolKind::ObjectCast`]) so no second traversal can drift out of step with the AST. +pub fn program_uses_object_cast(program: &[crate::parser::ast::Stmt]) -> bool { + detect::first_reference(program, Symbol::syntactic(SymbolKind::ObjectCast)).is_some() +} + +/// Prepends the object-cast helpers when the program contains an object cast; otherwise +/// returns the program unchanged so unrelated binaries pay nothing. The prelude is hoisted +/// function declarations only, so prepending does not change top-level execution order. +pub fn inject_if_used( + program: Program, + inventory: &mut crate::optimize::reachability::PreludeInventory, +) -> Program { + if !program_uses_object_cast(&program) { + return program; + } + let mut combined = object_cast_declarations(); + inventory.record_program("object_cast", &combined); + combined.extend(program); + combined +} + +/// The name of the helper `ir_lower` calls for a source that cannot be an object. +pub(crate) const CAST_HELPER: &str = "__elephc_cast_object"; + +/// The name of the helper `ir_lower` calls for a runtime-typed source. +pub(crate) const DYNAMIC_CAST_HELPER: &str = "__elephc_cast_object_dynamic"; + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::ast::StmtKind; + + /// Parses a fixture the way the pipeline does, so the detector sees a real AST. + fn parsed(source: &str) -> Program { + let tokens = crate::lexer::tokenize(source).expect("test source must tokenize"); + crate::parser::parse(&tokens).expect("test source must parse") + } + + /// A program with no object cast must carry neither helper: the prelude is pay-for-use. + #[test] + fn a_program_without_an_object_cast_carries_nothing() { + assert!(!program_uses_object_cast(&parsed(" 1]` on the nominal object path instead of degrading to `mixed`. + #[test] + fn the_static_helper_returns_stdclass() { + let decl = object_cast_declarations() + .into_iter() + .next() + .expect("the static helper is declared first"); + let StmtKind::FunctionDecl { + name, return_type, .. + } = &decl.kind + else { + panic!("expected a function declaration"); + }; + assert_eq!(name.as_str(), CAST_HELPER); + assert_eq!( + return_type.as_ref(), + Some(&TypeExpr::Named(Name::from("stdClass".to_string()))) + ); + } +} diff --git a/src/opcache_prelude/detect.rs b/src/opcache_prelude/detect.rs index 52bb2c4c6f..9cdd19b211 100644 --- a/src/opcache_prelude/detect.rs +++ b/src/opcache_prelude/detect.rs @@ -44,7 +44,7 @@ use crate::names::Name; use crate::parser::ast::{ - CallableTarget, ClassConst, ClassMethod, ClassProperty, EnumCaseDecl, Expr, ExprKind, + CallableTarget, CastType, ClassConst, ClassMethod, ClassProperty, EnumCaseDecl, Expr, ExprKind, InstanceOfTarget, PackedField, Stmt, StmtKind, TraitUse, TypeExpr, }; use crate::span::Span; @@ -91,6 +91,12 @@ pub(crate) enum SymbolKind { AsymmetricVisibility, /// A TYPED CLASS CONSTANT (`const string N = 'v'`), a PHP 8.3 form. TypedClassConst, + /// The OBJECT CAST (`(object) $value`), matched as a syntactic form rather than by name. + /// + /// `object_cast_prelude` injects the PHP helper the cast lowers to, and it must be + /// injected exactly when the program spells the cast anywhere — including inside a + /// function body, a class member initializer, or a closure. + ObjectCast, } /// How a call's first argument narrows a FUNCTION match. @@ -273,7 +279,8 @@ fn name_is(name: &Name, target: Symbol<'_>) -> bool { | SymbolKind::PipeOperator | SymbolKind::PropertyHooks | SymbolKind::AsymmetricVisibility - | SymbolKind::TypedClassConst => false, + | SymbolKind::TypedClassConst + | SymbolKind::ObjectCast => false, } } @@ -294,7 +301,8 @@ fn const_name_is(name: &Name, target: Symbol<'_>) -> bool { | SymbolKind::PipeOperator | SymbolKind::PropertyHooks | SymbolKind::AsymmetricVisibility - | SymbolKind::TypedClassConst => false, + | SymbolKind::TypedClassConst + | SymbolKind::ObjectCast => false, } } @@ -509,7 +517,13 @@ fn expr_refs(expr: &Expr, target: Symbol<'_>) -> Option { } => expr_refs(condition, target) .or_else(|| expr_refs(then_expr, target)) .or_else(|| expr_refs(else_expr, target)), - ExprKind::Cast { expr, .. } | ExprKind::PtrCast { expr, .. } => expr_refs(expr, target), + ExprKind::Cast { + target: cast_target, + expr: inner, + } => (target.kind == SymbolKind::ObjectCast && *cast_target == CastType::Object) + .then_some(expr.span) + .or_else(|| expr_refs(inner, target)), + ExprKind::PtrCast { expr, .. } => expr_refs(expr, target), ExprKind::Closure { params, body, .. } => params_ref(params, target) .or_else(|| body.iter().find_map(|stmt| stmt_refs(stmt, target))), ExprKind::NamedArg { value, .. } => expr_refs(value, target), diff --git a/src/optimize/fold/casts.rs b/src/optimize/fold/casts.rs index 6566af4a0d..9e5f6f8d86 100644 --- a/src/optimize/fold/casts.rs +++ b/src/optimize/fold/casts.rs @@ -34,6 +34,8 @@ pub(super) fn try_fold_cast(target: &CastType, expr: &Expr) -> Option CastType::Bool if value.is_nan_float() => None, CastType::Bool => Some(ExprKind::BoolLiteral(value.truthy())), CastType::Array => None, + // `(object)` always allocates a fresh stdClass, so there is no literal to fold to. + CastType::Object => None, } } diff --git a/src/optimize/reachability/usage/expressions.rs b/src/optimize/reachability/usage/expressions.rs index 37cc307e80..5aaa8939cc 100644 --- a/src/optimize/reachability/usage/expressions.rs +++ b/src/optimize/reachability/usage/expressions.rs @@ -12,7 +12,7 @@ use std::collections::HashSet; use crate::names::{php_symbol_key, property_hook_get_method, property_hook_set_method}; use crate::parser::ast::{ - CallableTarget, Expr, ExprKind, InstanceOfTarget, StaticReceiver, TypeExpr, + CallableTarget, CastType, Expr, ExprKind, InstanceOfTarget, StaticReceiver, TypeExpr, }; use crate::types::FunctionSig; @@ -139,8 +139,21 @@ impl Scanner<'_> { ExprKind::BinaryOp { left, right, .. } => { self.scan_expr(left); self.scan_expr(right); } ExprKind::Negate(e) | ExprKind::Not(e) | ExprKind::BitNot(e) | ExprKind::Throw(e) | ExprKind::Clone(e) | ExprKind::ErrorSuppress(e) | ExprKind::Print(e) - | ExprKind::Spread(e) | ExprKind::Cast { expr: e, .. } | ExprKind::PtrCast { expr: e, .. } + | ExprKind::Spread(e) | ExprKind::PtrCast { expr: e, .. } | ExprKind::YieldFrom(e) | ExprKind::IncludeValue { path: e, .. } => self.scan_expr(e), + // A `(object)` cast has no call syntax, but `ir_lower::expr::lower_cast` lowers it + // to a call into the `object_cast_prelude` helpers. Recording both here is what + // keeps the cast's callee reachable — otherwise the prelude is pruned and the + // lowered call resolves to nothing. The dynamic helper calls the static one, so + // the static helper is reachable through either arm, but both are recorded so a + // future change to the lowering's arm selection cannot silently prune one. + ExprKind::Cast { target, expr: e } => { + if matches!(target, CastType::Object) { + self.record_callable(crate::object_cast_prelude::CAST_HELPER); + self.record_callable(crate::object_cast_prelude::DYNAMIC_CAST_HELPER); + } + self.scan_expr(e); + } ExprKind::NullCoalesce { value, default } | ExprKind::ShortTernary { value, default } | ExprKind::Pipe { value, callable: default } => { self.scan_expr(value); self.scan_expr(default); } ExprKind::Assignment { target, value, result_target, prelude, .. } => { diff --git a/src/parser/ast/expr.rs b/src/parser/ast/expr.rs index 986e8562de..020216dd6a 100644 --- a/src/parser/ast/expr.rs +++ b/src/parser/ast/expr.rs @@ -263,6 +263,7 @@ pub enum CastType { String, Bool, Array, + Object, } #[derive(Debug, Clone, PartialEq)] diff --git a/src/parser/expr/calls.rs b/src/parser/expr/calls.rs index cceee7605b..07a5c8ae6a 100644 --- a/src/parser/expr/calls.rs +++ b/src/parser/expr/calls.rs @@ -137,6 +137,7 @@ pub(super) fn peek_cast(tokens: &[SpannedToken], pos: usize) -> Option Some(CastType::Bool) } Token::Identifier(name) if name.eq_ignore_ascii_case("array") => Some(CastType::Array), + Token::Identifier(name) if name.eq_ignore_ascii_case("object") => Some(CastType::Object), _ => None, } } diff --git a/src/php_profile/sensitivity.rs b/src/php_profile/sensitivity.rs index cf0454b88c..ccb6934425 100644 --- a/src/php_profile/sensitivity.rs +++ b/src/php_profile/sensitivity.rs @@ -264,7 +264,8 @@ pub fn scan(program: &[Stmt], web: bool) -> Vec { SymbolKind::PipeOperator | SymbolKind::PropertyHooks | SymbolKind::AsymmetricVisibility - | SymbolKind::TypedClassConst => Symbol::syntactic(watched.symbol_kind), + | SymbolKind::TypedClassConst + | SymbolKind::ObjectCast => Symbol::syntactic(watched.symbol_kind), }; detect::first_reference(program, symbol).map(|span| Sensitivity { symbol: watched.symbol, diff --git a/src/pipeline.rs b/src/pipeline.rs index 2032e4f366..41ddd1b594 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -243,6 +243,15 @@ pub(crate) fn compile(config: CliConfig) { let ast = var_export_prelude::inject_if_used(ast, &mut prelude_inventory); timings.record_since("var-export-prelude", phase_started); + // Inject the object-cast prelude (two pure elephc-PHP functions) only when the program + // spells a `(object)` cast, so other binaries carry nothing. Runs after include + // resolution so a cast inside an include is detected, and before name resolution so the + // call `ir_lower` synthesizes for the cast resolves to the injected declaration. + crate::progress::phase("object-cast-prelude"); + let phase_started = Instant::now(); + let ast = crate::object_cast_prelude::inject_if_used(ast, &mut prelude_inventory); + timings.record_since("object-cast-prelude", phase_started); + // Inject the OPcache preludes (pure elephc-PHP functions): `opcache_get_configuration()` // returns a compile-time array literal built from the version-keyed OPcache // directive matrix, and `opcache_reset()` returns the compile-time cache-enabled diff --git a/src/synthetic_class/print.rs b/src/synthetic_class/print.rs index 8557200b70..738782a63e 100644 --- a/src/synthetic_class/print.rs +++ b/src/synthetic_class/print.rs @@ -921,6 +921,7 @@ fn cast_type(target: &CastType) -> &'static str { CastType::String => "string", CastType::Bool => "bool", CastType::Array => "array", + CastType::Object => "object", } } diff --git a/src/synthetic_class/transcribe.rs b/src/synthetic_class/transcribe.rs index d09081e3ce..a6189f8dbe 100644 --- a/src/synthetic_class/transcribe.rs +++ b/src/synthetic_class/transcribe.rs @@ -1177,6 +1177,7 @@ fn cast_type(target: &CastType) -> &'static str { CastType::String => "String", CastType::Bool => "Bool", CastType::Array => "Array", + CastType::Object => "Object", } } diff --git a/src/types/checker/inference/expr/basic.rs b/src/types/checker/inference/expr/basic.rs index a47a8aca5c..0d31a896ab 100644 --- a/src/types/checker/inference/expr/basic.rs +++ b/src/types/checker/inference/expr/basic.rs @@ -403,6 +403,23 @@ impl Checker { PhpType::Mixed | PhpType::Union(_) ) => PhpType::Mixed, CastType::Array => PhpType::Array(Box::new(PhpType::Mixed)), + // `(object)` is the identity on an object, so the static class survives the + // cast; a runtime-typed source may already hold an unrelated class, which + // only `mixed` can describe. Every other source becomes a fresh stdClass. + CastType::Object + if matches!(source_ty.codegen_repr(), PhpType::Object(_)) => + { + source_ty.clone() + } + CastType::Object + if matches!( + source_ty.codegen_repr(), + PhpType::Mixed | PhpType::Union(_) + ) => + { + PhpType::Mixed + } + CastType::Object => PhpType::Object("stdClass".to_string()), }) } _ => unreachable!("non-basic expression routed to basic inference"), diff --git a/src/types/checker/loop_storage.rs b/src/types/checker/loop_storage.rs index ec26a71986..8e900cd57d 100644 --- a/src/types/checker/loop_storage.rs +++ b/src/types/checker/loop_storage.rs @@ -438,7 +438,7 @@ fn precise_scalar_expr_type(value: &Expr) -> Option { CastType::Float => Some(PhpType::Float), CastType::String => Some(PhpType::Str), CastType::Bool => Some(PhpType::Bool), - CastType::Array => None, + CastType::Array | CastType::Object => None, }, ExprKind::ErrorSuppress(inner) => precise_scalar_expr_type(inner), _ => None, diff --git a/tests/codegen/casts_and_constants.rs b/tests/codegen/casts_and_constants.rs index 5a04d2bd0f..c87a45b949 100644 --- a/tests/codegen/casts_and_constants.rs +++ b/tests/codegen/casts_and_constants.rs @@ -11,6 +11,8 @@ use crate::support::*; #[path = "casts_and_constants/casts.rs"] mod casts; +#[path = "casts_and_constants/object_cast.rs"] +mod object_cast; #[path = "casts_and_constants/introspection.rs"] mod introspection; #[path = "casts_and_constants/predicates.rs"] diff --git a/tests/codegen/casts_and_constants/object_cast.rs b/tests/codegen/casts_and_constants/object_cast.rs new file mode 100644 index 0000000000..a13f21bb0f --- /dev/null +++ b/tests/codegen/casts_and_constants/object_cast.rs @@ -0,0 +1,201 @@ +//! Purpose: +//! Integration or regression tests for end-to-end codegen coverage of PHP's `(object)` cast: +//! array-to-stdClass key projection, the `scalar` property for non-array scalars, the empty +//! object for `null`, and the identity an object source keeps. +//! +//! Called from: +//! - `cargo test` through Rust's test harness. +//! +//! Key details: +//! - Inline PHP fixtures are compiled to native binaries and assertions compare stdout. +//! - Expected output is verbatim reference PHP for the same program (issue #836). + +use super::*; + +/// The issue #836 reproduction: an array literal casts to a stdClass keyed by the array's +/// keys, and the property reads back. The parser used to reject the cast outright with +/// `Expected ']'`. +#[test] +fn test_object_cast_of_array_literal_exposes_keys_as_properties() { + let out = compile_and_run( + r#" 'Laravel']; +echo $value->name; +"#, + ); + assert_eq!(out, "Laravel"); +} + +/// Every key of a multi-entry array becomes a property, and the values keep their PHP types. +#[test] +fn test_object_cast_of_array_keeps_every_key_and_value_type() { + let out = compile_and_run( + r#" 'Laravel', 'version' => 12, 'ratio' => 1.5, 'on' => true]; +echo $value->name, "|", $value->version, "|", $value->ratio, "|"; +var_dump($value->on); +"#, + ); + assert_eq!(out, "Laravel|12|1.5|bool(true)\n"); +} + +/// An integer-keyed array produces the numeric-STRING property names PHP produces, which are +/// reachable through the `{'0'}` form. +#[test] +fn test_object_cast_of_integer_keyed_array_produces_numeric_string_names() { + let out = compile_and_run( + r#"{'0'}, $value->{'1'}; +"#, + ); + assert_eq!(out, "xy"); +} + +/// A cast of an array held in a variable goes through the same conversion as a literal. +#[test] +fn test_object_cast_of_array_variable() { + let out = compile_and_run( + r#" 'v']; +$value = (object) $source; +echo $value->k; +"#, + ); + assert_eq!(out, "v"); +} + +/// Every non-array, non-null, non-object source lands on php-src's literal `scalar` property. +#[test] +fn test_object_cast_of_scalars_uses_the_scalar_property() { + let out = compile_and_run( + r#"scalar, "|", ((object) 'text')->scalar, "|"; +var_dump(((object) true)->scalar); +var_dump(((object) 1.5)->scalar); +"#, + ); + assert_eq!(out, "42|text|bool(true)\nfloat(1.5)\n"); +} + +/// `(object) null` is an EMPTY stdClass, not an object carrying a null `scalar` property. +#[test] +fn test_object_cast_of_null_is_an_empty_stdclass() { + let out = compile_and_run( + r#"n = 7; +echo $box->n; +"#, + ); + assert_eq!(out, "bool(true)\n7"); +} + +/// A runtime-typed source reaches the dynamic helper, which must keep the identity arm for an +/// object payload and still convert every other payload. +#[test] +fn test_object_cast_of_a_mixed_source_handles_both_arms() { + let out = compile_and_run( + r#" 'arr']; } + return 9; +} +$a = (object) pick(0); +$b = (object) pick(1); +$c = (object) pick(2); +echo $a->s, "|", $b->k, "|", $c->scalar; +"#, + ); + assert_eq!(out, "tag|arr|9"); +} + +/// The cast result is a real stdClass to the class-introspection surface. +#[test] +fn test_object_cast_result_is_a_stdclass() { + let out = compile_and_run( + r#" 'Laravel']; +var_dump($value instanceof stdClass); +echo get_class($value); +"#, + ); + assert_eq!(out, "bool(true)\nstdClass"); +} + +/// `(array)` of an object cast round-trips the entries back to the original array. +#[test] +fn test_object_cast_round_trips_through_an_array_cast() { + let out = compile_and_run( + r#" 'Laravel', 'version' => 12]; +$back = (array) $value; +echo $back['name'], "|", $back['version'], "|", count($back); +"#, + ); + assert_eq!(out, "Laravel|12|2"); +} + +/// The cast's source is evaluated EXACTLY ONCE: the lowering stores it in a synthetic local +/// before the helper call, so a source with a side effect cannot run twice. +#[test] +fn test_object_cast_evaluates_its_source_once() { + let out = compile_and_run( + r#" 'v']; +} +$value = (object) source(); +echo $value->k, "|", $calls; +"#, + ); + assert_eq!(out, "v|1"); +} + +/// The cast works inside a function body, where the prelude helper is reached through an +/// ordinary call rather than from top-level code. +#[test] +fn test_object_cast_inside_a_function() { + let out = compile_and_run( + r#" 'Laravel'])->name; +"#, + ); + assert_eq!(out, "Laravel"); +} + +/// The cast spelling is case-insensitive, as every PHP cast is. +#[test] +fn test_object_cast_spelling_is_case_insensitive() { + let out = compile_and_run( + r#" 'Laravel']; +echo $value->name; +"#, + ); + assert_eq!(out, "Laravel"); +} diff --git a/tests/codegen/support/compiler.rs b/tests/codegen/support/compiler.rs index 56093aa865..e834020ee2 100644 --- a/tests/codegen/support/compiler.rs +++ b/tests/codegen/support/compiler.rs @@ -283,6 +283,8 @@ fn try_compile_source_to_asm_with_defines_repr( let resolved = elephc::tz_prelude::inject_if_used(resolved, false, &mut prelude_inventory); let resolved = elephc::list_id_prelude::inject_if_used(resolved, &mut prelude_inventory); let resolved = elephc::var_export_prelude::inject_if_used(resolved, &mut prelude_inventory); + let resolved = + elephc::object_cast_prelude::inject_if_used(resolved, &mut prelude_inventory); let resolved = elephc::image_prelude::inject_if_used(resolved, false, &mut prelude_inventory); let resolved = elephc::hash_prelude::inject_if_used(resolved, false, &mut prelude_inventory); diff --git a/tests/parser_tests/expressions/basics.rs b/tests/parser_tests/expressions/basics.rs index be72fbffca..e66395cba2 100644 --- a/tests/parser_tests/expressions/basics.rs +++ b/tests/parser_tests/expressions/basics.rs @@ -230,6 +230,22 @@ fn test_cast_keywords_are_case_insensitive() { } } +/// Verifies that ` 1];` parses to a `Cast` expression with target +/// `Object`. The parser used to stop at the cast and report `Expected ']'` (issue #836). +#[test] +fn test_cast_object_parses() { + let stmts = parse_source(" 1];"); + match &stmts[0].kind { + StmtKind::Assign { value, .. } => match &value.kind { + ExprKind::Cast { target, .. } => { + assert_eq!(target, &elephc::parser::ast::CastType::Object); + } + other => panic!("expected cast expression, got {:?}", other), + }, + other => panic!("expected assignment statement, got {:?}", other), + } +} + /// Verifies that ` Date: Sun, 13 Sep 2026 18:48:55 +0200 Subject: [PATCH 2/2] fix(object-cast): inject past autoload, reject the reserved name, cover ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #988. Autoloaded casts missed the helpers. The prelude sat with every other one, before name resolution — but the cast is detected SYNTACTICALLY and an autoloaded class file is not part of the AST until `autoload::run`. A program whose only `(object)` cast lived in a PSR-4 class therefore got no injection, and codegen failed with `unsupported EIR backend feature: language construct __elephc_cast_object`. Injection moved past `autoload::run`, with the declarations name-resolved on the way in exactly as `autoload::run` resolves the files it splices. The three test harnesses that mirror the pipeline moved with it. The helper names are now reserved. `ir_lower` lowers every `(object)` cast to a call on them, so a user function of the same name would not merely shadow the prelude — it would BECOME the cast's semantics. A program that declares either one and also spells a cast is rejected by name; declaring one WITHOUT a cast is still fine, since the prelude is pay-for-use. `detect::first_declaration` is added alongside `program_declares` so the diagnostic can point at the offending declaration. The dynamic helper corrupted an object payload's refcount. Its `is_object()` arm returned the parameter early, and a `mixed` function with a CONDITIONAL `return $param;` alongside another return miscompiles today — heap debug reports `bad refcount`. The body now converts in place and returns `$value` once, the spelling that is clean. This is a PRE-EXISTING return-alias hole reachable from ordinary user PHP (`function f(mixed $v): mixed { if (is_object($v)) { return $v; } return $v; }` reproduces it with no cast involved); the helper is written to stay out of it rather than to work around it, and the hole is reported separately. Tests: the required lexer and error coverage the repository asks of a new language construct (the cast's token window and case handling; a missing operand, an unterminated window, and the reserved-name diagnostic), an `examples/object-cast/` program whose output was diffed against reference PHP, six ownership fixtures under `tests/codegen/runtime_gc/` asserting a clean heap for a temporary array, a repeated cast, an owned string, each arm of a Mixed source, an object source that must be neither copied nor released, and a result dropped at the end of a function, and a multi-file fixture pinning the autoload case. Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr --- examples/object-cast/.gitignore | 3 + examples/object-cast/main.php | 41 +++++ src/object_cast_prelude.rs | 70 ++++++-- src/opcache_prelude/detect.rs | 23 +++ src/pipeline.rs | 31 +++- .../casts_and_constants/object_cast.rs | 29 ++++ tests/codegen/runtime_gc.rs | 2 + tests/codegen/runtime_gc/object_cast.rs | 161 ++++++++++++++++++ tests/codegen/support/compiler.rs | 6 +- tests/codegen/support/projects.rs | 9 + tests/error_tests.rs | 6 + tests/error_tests/syntax.rs | 43 +++++ tests/lexer_tests/syntax.rs | 31 ++++ 13 files changed, 434 insertions(+), 21 deletions(-) create mode 100644 examples/object-cast/.gitignore create mode 100644 examples/object-cast/main.php create mode 100644 tests/codegen/runtime_gc/object_cast.rs diff --git a/examples/object-cast/.gitignore b/examples/object-cast/.gitignore new file mode 100644 index 0000000000..fc9576aeb9 --- /dev/null +++ b/examples/object-cast/.gitignore @@ -0,0 +1,3 @@ +*.s +*.o +main diff --git a/examples/object-cast/main.php b/examples/object-cast/main.php new file mode 100644 index 0000000000..5656beec1a --- /dev/null +++ b/examples/object-cast/main.php @@ -0,0 +1,41 @@ +timeout` instead of `$options['timeout']`. + +function settings(string $host, int $port, bool $secure): stdClass +{ + return (object) ["host" => $host, "port" => $port, "secure" => $secure]; +} + +$config = settings("localhost", 6432, true); + +echo "host: " . $config->host . "\n"; +echo "port: " . $config->port . "\n"; +echo "secure: " . ($config->secure ? "yes" : "no") . "\n"; + +// Array keys become property names, so the object round-trips back to the same array. +$back = (array) $config; +echo "keys: " . implode(", ", array_keys($back)) . "\n"; + +// A non-array value lands on PHP's `scalar` property. +$wrapped = (object) "just a string"; +echo "scalar: " . $wrapped->scalar . "\n"; + +// null becomes an empty object rather than one holding a null `scalar`. +$empty = (object) null; +echo "empty: " . count(get_object_vars($empty)) . " properties\n"; + +// An object is returned unchanged — the cast is the identity, not a copy. +class Endpoint +{ + public function __construct(public string $url) {} +} + +$endpoint = new Endpoint("https://example.test"); +$same = (object) $endpoint; +$same->url = "https://changed.test"; + +echo "same: " . ($same === $endpoint ? "yes" : "no") . "\n"; +echo "url: " . $endpoint->url . "\n"; diff --git a/src/object_cast_prelude.rs b/src/object_cast_prelude.rs index 088811744f..9320d25e09 100644 --- a/src/object_cast_prelude.rs +++ b/src/object_cast_prelude.rs @@ -4,8 +4,8 @@ //! `__elephc_cast_object_dynamic` (a runtime-typed source that may already hold an object). //! //! Called from: -//! - `crate::pipeline::compile()` and the codegen test harness via `inject_if_used`, before -//! name resolution, so `ir_lower::expr::lower_cast` can lower a `(object)` cast to an +//! - `crate::pipeline::compile()` and the codegen test harness via `inject_if_used`, AFTER +//! `autoload::run`, so `ir_lower::expr::lower_cast` can lower a `(object)` cast to an //! ordinary call to the injected declaration. //! //! Key details: @@ -26,12 +26,18 @@ //! - Pay-for-use: injected only when the program spells an object cast anywhere, detected //! through the shared exhaustive walk in `opcache_prelude::detect` rather than a second //! traversal of its own. +//! - Injection runs LATE, past the pipeline's own name-resolution pass, because the cast is +//! detected syntactically and an autoloaded class file is not part of the AST before +//! `autoload::run`. The declarations are name-resolved on the way in, the way `autoload::run` +//! resolves the files it splices. +use crate::errors::CompileError; use crate::names::Name; use crate::parser::ast::{BinOp, CastType, Program, TypeExpr}; use crate::opcache_prelude::detect::{self, Symbol, SymbolKind}; +use crate::span::Span; use crate::synthetic_class::{ - e_assign, e_binop, e_call, e_cast, e_dyn_prop, e_null, e_var, function, + e_assign, e_binop, e_call, e_cast, e_dyn_prop, e_not, e_null, e_var, function, internal_declarations, s_assign, s_expr, s_foreach, s_if, s_prop_assign, s_return, t_mixed, }; @@ -90,18 +96,31 @@ fn cast_object_decl() -> crate::parser::ast::Stmt { /// `===` against the source still holds and a mutation through either name is visible /// through the other. That arm is why this helper returns `mixed` rather than `stdClass` — /// the instance it hands back can be of any class. +/// +/// The body CONVERTS IN PLACE and returns `$value` once, rather than returning the parameter +/// early from an `is_object()` arm and the converted object from a second `return`. The two +/// spellings are equivalent PHP, but the early-return one is miscompiled today: a `mixed` +/// function with a CONDITIONAL `return $param;` alongside another return corrupts the +/// refcount of an object payload (`heap debug detected bad refcount`), where the same function +/// with a single trailing `return $param;` is clean. That is a pre-existing return-alias hole +/// reachable from ordinary user PHP — `function f(mixed $v): mixed { if (is_object($v)) { +/// return $v; } return $v; }` reproduces it with no cast involved — and this shape is written +/// to stay out of it rather than to work around it here. fn cast_object_dynamic_decl() -> crate::parser::ast::Stmt { function("__elephc_cast_object_dynamic") .param("value", t_mixed()) .returns(t_mixed()) .body(vec![ s_if( - e_call("is_object", vec![e_var("value")]), - vec![s_return(e_var("value"))], + e_not(e_call("is_object", vec![e_var("value")])), + vec![s_assign( + "value", + e_call("__elephc_cast_object", vec![e_var("value")]), + )], vec![], None, ), - s_return(e_call("__elephc_cast_object", vec![e_var("value")])), + s_return(e_var("value")), ]) .build() } @@ -119,20 +138,51 @@ pub fn program_uses_object_cast(program: &[crate::parser::ast::Stmt]) -> bool { detect::first_reference(program, Symbol::syntactic(SymbolKind::ObjectCast)).is_some() } +/// Returns the span of a user declaration of either helper name, if the program has one. +fn declared_helper(program: &[crate::parser::ast::Stmt]) -> Option<(&'static str, Span)> { + for helper in [CAST_HELPER, DYNAMIC_CAST_HELPER] { + if let Some(span) = detect::first_declaration(program, helper) { + return Some((helper, span)); + } + } + None +} + /// Prepends the object-cast helpers when the program contains an object cast; otherwise /// returns the program unchanged so unrelated binaries pay nothing. The prelude is hoisted /// function declarations only, so prepending does not change top-level execution order. +/// +/// Injection runs AFTER the pipeline's name-resolution pass (see the call site in +/// `pipeline::compile`, which is positioned there so a cast inside an autoloaded class file is +/// detected at all), so the declarations are resolved here the way `autoload::run` resolves the +/// files it splices in. +/// +/// A program that declares either helper name itself is REJECTED rather than silently having +/// its own definition win: `ir_lower` lowers every `(object)` cast to a call on that name, so a +/// user definition would not merely shadow the prelude, it would become the cast's semantics. +/// Prepending regardless was no better — the checker reported `Duplicate function declaration` +/// at the user's own line with no hint of why. pub fn inject_if_used( program: Program, inventory: &mut crate::optimize::reachability::PreludeInventory, -) -> Program { +) -> Result { if !program_uses_object_cast(&program) { - return program; + return Ok(program); + } + if let Some((helper, span)) = declared_helper(&program) { + return Err(CompileError::new( + span, + &format!( + "Cannot declare {}(): the name is reserved for the compiler's `(object)` cast \ + helper, which this program's `(object)` cast is lowered to. Rename the function.", + helper + ), + )); } - let mut combined = object_cast_declarations(); + let mut combined = crate::name_resolver::resolve(object_cast_declarations())?; inventory.record_program("object_cast", &combined); combined.extend(program); - combined + Ok(combined) } /// The name of the helper `ir_lower` calls for a source that cannot be an object. diff --git a/src/opcache_prelude/detect.rs b/src/opcache_prelude/detect.rs index 9cdd19b211..c0bc27213c 100644 --- a/src/opcache_prelude/detect.rs +++ b/src/opcache_prelude/detect.rs @@ -264,6 +264,29 @@ pub(crate) fn program_declares(program: &[Stmt], target: &str) -> bool { program.iter().any(|stmt| stmt_declares(stmt, target)) } +/// Returns the span of the program's own declaration of `target`, if it has one. +/// +/// [`program_declares`] answers the question a prelude that lets the USER definition win needs; +/// this answers the one a prelude that must REJECT the collision needs, so the diagnostic can +/// point at the offending declaration instead of at the start of the program. +pub(crate) fn first_declaration(program: &[Stmt], target: &str) -> Option { + program.iter().find_map(|stmt| stmt_declaration_span(stmt, target)) +} + +/// Returns the span of one statement's declaration of `target`, recursing into the same +/// declaration-carrying blocks [`stmt_declares`] walks. +fn stmt_declaration_span(stmt: &Stmt, target: &str) -> Option { + match &stmt.kind { + StmtKind::FunctionDecl { name, .. } if name.eq_ignore_ascii_case(target) => Some(stmt.span), + StmtKind::NamespaceBlock { body, .. } + | StmtKind::IncludeOnceGuard { body, .. } + | StmtKind::Synthetic(body) => body + .iter() + .find_map(|stmt| stmt_declaration_span(stmt, target)), + _ => None, + } +} + /// Returns whether a CALL position names `target`, compared case-insensitively on its /// unqualified last segment. /// diff --git a/src/pipeline.rs b/src/pipeline.rs index 41ddd1b594..425a4d05d2 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -243,15 +243,6 @@ pub(crate) fn compile(config: CliConfig) { let ast = var_export_prelude::inject_if_used(ast, &mut prelude_inventory); timings.record_since("var-export-prelude", phase_started); - // Inject the object-cast prelude (two pure elephc-PHP functions) only when the program - // spells a `(object)` cast, so other binaries carry nothing. Runs after include - // resolution so a cast inside an include is detected, and before name resolution so the - // call `ir_lower` synthesizes for the cast resolves to the injected declaration. - crate::progress::phase("object-cast-prelude"); - let phase_started = Instant::now(); - let ast = crate::object_cast_prelude::inject_if_used(ast, &mut prelude_inventory); - timings.record_since("object-cast-prelude", phase_started); - // Inject the OPcache preludes (pure elephc-PHP functions): `opcache_get_configuration()` // returns a compile-time array literal built from the version-keyed OPcache // directive matrix, and `opcache_reset()` returns the compile-time cache-enabled @@ -443,6 +434,28 @@ pub(crate) fn compile(config: CliConfig) { }; timings.record_since("autoload-run", phase_started); + // Inject the object-cast prelude (two pure elephc-PHP functions) only when the program + // spells a `(object)` cast, so other binaries carry nothing. + // + // Runs AFTER `autoload::run`, unlike every prelude above, because the cast is detected + // syntactically and an autoloaded class file is not part of the AST until here: a program + // whose only `(object)` cast lives in a PSR-4 class saw no injection, and the call + // `ir_lower` synthesizes for the cast then failed codegen with + // `unsupported EIR backend feature: language construct __elephc_cast_object`. The + // declarations are name-resolved on the way in, exactly as `autoload::run` resolves the + // files it splices, because the position is past the pipeline's own name-resolution pass. + crate::progress::phase("object-cast-prelude"); + let phase_started = Instant::now(); + let ast = match crate::object_cast_prelude::inject_if_used(ast, &mut prelude_inventory) { + Ok(injected) => injected, + Err(e) => { + crate::progress::clear(); + errors::report(&e); + process::exit(1); + } + }; + timings.record_since("object-cast-prelude", phase_started); + // Desugar PHP's argument-introspection constructs (`func_num_args`, `func_get_args`, // `func_get_arg`) into plain PHP: every function scope that uses one gains the hidden // `mixed ...$__elephc_func_args` parameter, so the surplus positional arguments PHP diff --git a/tests/codegen/casts_and_constants/object_cast.rs b/tests/codegen/casts_and_constants/object_cast.rs index a13f21bb0f..4a89a0e5ed 100644 --- a/tests/codegen/casts_and_constants/object_cast.rs +++ b/tests/codegen/casts_and_constants/object_cast.rs @@ -188,6 +188,35 @@ echo wrap(['name' => 'Laravel'])->name; assert_eq!(out, "Laravel"); } +/// A `(object)` cast whose ONLY occurrence is inside a compile-time-autoloaded class file must +/// still get the prelude helpers it is lowered to. +/// +/// The prelude is detected syntactically, and an autoloaded class file is not part of the AST +/// until `autoload::run`. Injecting before that pass — where every other prelude sits — left +/// this program with a call to an undeclared helper and failed code generation with +/// `unsupported EIR backend feature: language construct __elephc_cast_object`. +#[test] +fn test_object_cast_inside_an_autoloaded_class_gets_the_prelude() { + let out = compile_and_run_files( + &[ + ( + "composer.json", + r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#, + ), + ( + "src/Maker.php", + "make([\"name\" => \"Laravel\"])->name;\n", + ), + ], + "main.php", + ); + assert_eq!(out, "Laravel"); +} + /// The cast spelling is case-insensitive, as every PHP cast is. #[test] fn test_object_cast_spelling_is_case_insensitive() { diff --git a/tests/codegen/runtime_gc.rs b/tests/codegen/runtime_gc.rs index f26f63affa..07a6fce13f 100644 --- a/tests/codegen/runtime_gc.rs +++ b/tests/codegen/runtime_gc.rs @@ -9,6 +9,8 @@ #[path = "runtime_gc/basics.rs"] mod basics; +#[path = "runtime_gc/object_cast.rs"] +mod object_cast; #[path = "runtime_gc/nullable_string_return.rs"] mod nullable_string_return; #[path = "runtime_gc/iconv.rs"] diff --git a/tests/codegen/runtime_gc/object_cast.rs b/tests/codegen/runtime_gc/object_cast.rs new file mode 100644 index 0000000000..4494adad3c --- /dev/null +++ b/tests/codegen/runtime_gc/object_cast.rs @@ -0,0 +1,161 @@ +//! Purpose: +//! Integration or regression tests for the OWNERSHIP path of PHP's `(object)` cast: the source +//! is lowered into a synthetic local and handed to an ordinary user-function call, so a +//! refcounted array, string or boxed Mixed source must be released exactly once and an object +//! source must be neither copied nor released. +//! +//! Called from: +//! - `cargo test` through Rust's test harness. +//! +//! Key details: +//! - Every fixture derives its value from `$argc` so constant folding cannot erase the +//! allocation under test before lowering sees it, and loops the cast so a per-iteration leak +//! shows up as a growing heap rather than a single stray block. +//! - Each assertion checks `HEAP DEBUG: leak summary: clean` alongside the output: a wrong +//! ownership decision here is a silent leak, not a wrong answer. + +use crate::support::*; + +/// A temporary array source is released once: the cast consumes it into the stdClass's +/// properties and nothing keeps the array alive. +#[test] +fn test_object_cast_of_a_temporary_array_leaves_a_clean_heap() { + let out = compile_and_run_with_heap_debug( + r#" "n" . $argc, "port" => $argc]; +echo $value->name, "|", $value->port; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "n1|1"); + assert!( + out.stderr.contains("HEAP DEBUG: leak summary: clean"), + "expected a clean heap, got: {}", + out.stderr + ); +} + +/// Casting in a LOOP is what turns a per-cast leak into a visible one: the array source, the +/// stdClass and its property boxes are all allocated each iteration. +#[test] +fn test_repeated_object_casts_leave_a_clean_heap() { + let out = compile_and_run_with_heap_debug( + r#" "v" . $i, "n" => $i + $argc]; + $total = $total + $value->n; +} +echo $total; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "20100"); + assert!( + out.stderr.contains("HEAP DEBUG: leak summary: clean"), + "expected a clean heap, got: {}", + out.stderr + ); +} + +/// An owned STRING source reaches the `scalar` property and is released once. +#[test] +fn test_object_cast_of_an_owned_string_leaves_a_clean_heap() { + let out = compile_and_run_with_heap_debug( + r#"scalar) === 0) { echo "bad"; } +} +echo "ok"; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "ok"); + assert!( + out.stderr.contains("HEAP DEBUG: leak summary: clean"), + "expected a clean heap, got: {}", + out.stderr + ); +} + +/// A runtime-typed (boxed `mixed`) source takes the dynamic helper, whose identity arm returns +/// the payload unchanged. The box must still be released exactly once on every arm. +#[test] +fn test_object_cast_of_a_mixed_source_leaves_a_clean_heap() { + let out = compile_and_run_with_heap_debug( + r#" "a" . $i]; } + return "s" . $i; +} +$count = 0; +for ($i = 0; $i < 200; $i++) { + $value = (object) pick($i + $argc - 1); + $count = $count + 1; +} +echo $count; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "200"); + assert!( + out.stderr.contains("HEAP DEBUG: leak summary: clean"), + "expected a clean heap, got: {}", + out.stderr + ); +} + +/// An OBJECT source is the identity: no copy is allocated, and the cast must not release the +/// instance the program still holds under its own name. +#[test] +fn test_object_cast_of_an_object_neither_copies_nor_releases() { + let out = compile_and_run_with_heap_debug( + r#"n = $i + $argc; + $cast = (object) $box; + $cast->n = $cast->n + 1; + if ($box->n !== $i + $argc + 1) { echo "bad"; } +} +echo "ok"; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "ok"); + assert!( + out.stderr.contains("HEAP DEBUG: leak summary: clean"), + "expected a clean heap, got: {}", + out.stderr + ); +} + +/// The cast result itself is an ordinary owned object: dropping it at the end of a function +/// releases the stdClass and every property box the conversion allocated. +#[test] +fn test_object_cast_result_dropped_in_a_function_leaves_a_clean_heap() { + let out = compile_and_run_with_heap_debug( + r#"k); +} +$total = 0; +for ($i = 0; $i < 200; $i++) { + $total = $total + width(["k" => "v" . $i . $argc]); +} +echo $total; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "890"); + assert!( + out.stderr.contains("HEAP DEBUG: leak summary: clean"), + "expected a clean heap, got: {}", + out.stderr + ); +} diff --git a/tests/codegen/support/compiler.rs b/tests/codegen/support/compiler.rs index e834020ee2..6573474a28 100644 --- a/tests/codegen/support/compiler.rs +++ b/tests/codegen/support/compiler.rs @@ -283,8 +283,6 @@ fn try_compile_source_to_asm_with_defines_repr( let resolved = elephc::tz_prelude::inject_if_used(resolved, false, &mut prelude_inventory); let resolved = elephc::list_id_prelude::inject_if_used(resolved, &mut prelude_inventory); let resolved = elephc::var_export_prelude::inject_if_used(resolved, &mut prelude_inventory); - let resolved = - elephc::object_cast_prelude::inject_if_used(resolved, &mut prelude_inventory); let resolved = elephc::image_prelude::inject_if_used(resolved, false, &mut prelude_inventory); let resolved = elephc::hash_prelude::inject_if_used(resolved, false, &mut prelude_inventory); @@ -293,6 +291,10 @@ fn try_compile_source_to_asm_with_defines_repr( let resolved = elephc::name_resolver::resolve(resolved).expect("name resolve failed"); let resolved = elephc::autoload::run(resolved, dir, &autoload_registry).expect("autoload failed"); + // Mirrors `pipeline::compile`: the object-cast prelude is injected AFTER autoloading, so a + // `(object)` cast that only appears in an autoloaded class file is still detected. + let resolved = elephc::object_cast_prelude::inject_if_used(resolved, &mut prelude_inventory) + .expect("object-cast prelude injection failed"); // Mirrors `pipeline::compile`: `func_num_args`/`func_get_args`/`func_get_arg` are // desugared into a hidden variadic parameter plus plain PHP after autoloading and // before the optimizer, so the checker and the backend only ever see ordinary PHP. diff --git a/tests/codegen/support/projects.rs b/tests/codegen/support/projects.rs index 12597240a7..ea94344995 100644 --- a/tests/codegen/support/projects.rs +++ b/tests/codegen/support/projects.rs @@ -291,6 +291,10 @@ pub(crate) fn compile_expect_type_error(source: &str) -> String { let resolved = elephc::name_resolver::resolve(resolved).expect("name resolve failed"); let resolved = elephc::autoload::run(resolved, &dir, &autoload_registry).expect("autoload failed"); + // Mirrors `pipeline::compile`: the object-cast prelude is injected AFTER autoloading, so a + // `(object)` cast that only appears in an autoloaded class file is still detected. + let resolved = elephc::object_cast_prelude::inject_if_used(resolved, &mut prelude_inventory) + .expect("object-cast prelude injection failed"); // Mirrors `pipeline::compile`: desugar `func_num_args`/`func_get_args`/`func_get_arg` // into a hidden variadic parameter plus plain PHP before the optimizer and the checker. let resolved = elephc::func_args::desugar(resolved).expect("func_args desugar failed"); @@ -436,6 +440,11 @@ pub(crate) fn compile_and_run_files_with_defines( let resolved = elephc::name_resolver::resolve(resolved).expect("name resolve failed"); let resolved = elephc::autoload::run(resolved, base_dir, &autoload_registry).expect("autoload failed"); + // Mirrors `pipeline::compile`: the object-cast prelude is injected AFTER autoloading, so a + // `(object)` cast that only appears in an autoloaded class file is still detected. + let mut prelude_inventory = elephc::optimize::reachability::PreludeInventory::new(); + let resolved = elephc::object_cast_prelude::inject_if_used(resolved, &mut prelude_inventory) + .expect("object-cast prelude injection failed"); // Mirrors `pipeline::compile`: desugar `func_num_args`/`func_get_args`/`func_get_arg` // into a hidden variadic parameter plus plain PHP before the optimizer and the checker. let resolved = elephc::func_args::desugar(resolved).expect("func_args desugar failed"); diff --git a/tests/error_tests.rs b/tests/error_tests.rs index 888150af79..5a3ec35670 100644 --- a/tests/error_tests.rs +++ b/tests/error_tests.rs @@ -69,6 +69,11 @@ fn check_source_with_defines_and_options( let ast = elephc::curl_prelude::inject_if_used(ast, false, &mut prelude_inventory); let ast = elephc::xml_prelude::inject_if_used(ast, false, &mut prelude_inventory); let ast = elephc::name_resolver::resolve(ast).map_err(|e| e.message.clone())?; + // Mirrors `pipeline::compile`: the object-cast prelude is injected past name resolution, + // so a `(object)` cast has the helper it is lowered to and a program that declares that + // helper's own name is rejected here rather than reaching the checker as a duplicate. + let ast = elephc::object_cast_prelude::inject_if_used(ast, &mut prelude_inventory) + .map_err(|e| e.message.clone())?; // Mirrors `pipeline::compile`: `func_num_args`/`func_get_args`/`func_get_arg` are // desugared into a hidden variadic parameter plus plain PHP before the checker runs, so // their own diagnostics reach this harness instead of a bare `Undefined function`. @@ -88,6 +93,7 @@ fn check_source_full(src: &str) -> Result