diff --git a/src/codegen/lower_inst/builtins/arrays.rs b/src/codegen/lower_inst/builtins/arrays.rs index 1c771982c..a6b54cfa1 100644 --- a/src/codegen/lower_inst/builtins/arrays.rs +++ b/src/codegen/lower_inst/builtins/arrays.rs @@ -2024,7 +2024,8 @@ fn lower_user_sort_static_callback( super::ensure_arg_count(inst, name, 2)?; let array = expect_operand(inst, 0)?; let callback = expect_operand(inst, 1)?; - user_sort_element_type(ctx.value_php_type(array)?, name)?; + let elem_ty = user_sort_element_type(ctx.value_php_type(array)?, name)?; + let callback_arg_types = [elem_ty.clone(), elem_ty]; let source_local = source_load_local_slot(ctx, array)?; ensure_unique_sort_source(ctx, array)?; if let Some(slot) = source_local { @@ -2037,7 +2038,7 @@ fn lower_user_sort_static_callback( ctx, callback, &callback_owner, - Some(&[PhpType::Int, PhpType::Int]), + Some(&callback_arg_types), )?; return lower_user_sort_with_static_callback_binding(ctx, inst, array, callback_binding); } @@ -2046,7 +2047,7 @@ fn lower_user_sort_static_callback( lower_descriptor_callback_runtime( ctx, callback, - vec![PhpType::Int, PhpType::Int], + callback_arg_types.to_vec(), PhpType::Int, |ctx, wrapper_label, env_bytes| { let callback_arg_reg = abi::int_arg_reg_name(ctx.emitter.target, 0); @@ -2071,8 +2072,8 @@ fn lower_user_sort_static_callback( lower_runtime_string_descriptor_callback( ctx, callback, - Some(&PhpType::Array(Box::new(PhpType::Int))), - vec![PhpType::Int, PhpType::Int], + Some(&PhpType::Array(Box::new(callback_arg_types[0].clone()))), + callback_arg_types.to_vec(), PhpType::Int, name, |ctx, wrapper_label, env_bytes| { @@ -2100,7 +2101,7 @@ fn lower_user_sort_static_callback( ctx, callback, &callback_owner, - Some(&[PhpType::Int, PhpType::Int]), + Some(&callback_arg_types), )?; lower_user_sort_with_static_callback_binding(ctx, inst, array, callback_binding) } @@ -2265,18 +2266,23 @@ fn indexed_sort_element_type(ty: PhpType, name: &str, allow_strings: bool) -> Re /// /// User-comparator sorts (`usort`/`uasort`/`uksort`) permute existing /// pointer-sized slots through `__rt_usort`, so integer and object/refcounted -/// handles (each a single 8-byte payload) are sortable; the comparator decides -/// the ordering and receives each element by its handle. String elements are -/// rejected here exactly as before — their multi-word descriptors are not -/// permuted by the 8-byte slot sorter — so they keep producing a clear -/// unsupported-feature error rather than a corrupt sort. +/// handles and boxed `Mixed` cells (each a single 8-byte payload) are sortable; +/// the comparator decides the ordering and receives each element through an ABI +/// adapter when the runtime slot type differs from its declared parameters. +/// String elements are rejected here exactly as before — their multi-word +/// descriptors are not permuted by the 8-byte slot sorter — so they keep +/// producing a clear unsupported-feature error rather than a corrupt sort. fn user_sort_element_type(ty: PhpType, name: &str) -> Result { match ty.codegen_repr() { PhpType::Array(elem) => { let elem = elem.codegen_repr(); if matches!( elem, - PhpType::Int | PhpType::Void | PhpType::Never | PhpType::Object(_) + PhpType::Int + | PhpType::Void + | PhpType::Never + | PhpType::Mixed + | PhpType::Object(_) ) { return Ok(elem); } @@ -2674,11 +2680,31 @@ fn static_sort_callback_binding( Some(callback) => callback, None => static_callback_name_operand(ctx, value, owner)?, }; - if let Some(callee) = ctx.callable_function_by_name(&callback.name) { + if let Some((label, param_types, return_ty)) = ctx + .callable_function_by_name(&callback.name) + .map(|callee| { + ( + function_symbol(&callee.name), + callee + .params + .iter() + .map(|param| param.php_type.codegen_repr()) + .collect::>(), + callee.return_php_type.codegen_repr(), + ) + }) + { + let (label, env_source) = adapt_direct_callback_visible_args( + ctx, + label, + ¶m_types, + visible_arg_types, + owner, + )?; return Ok(StaticSortCallbackBinding { - label: function_symbol(&callee.name), - env_source: None, - return_ty: callee.return_php_type.codegen_repr(), + label, + env_source, + return_ty, }); } if callback.kind == StaticCallbackOperandKind::FirstClassCallable { @@ -2713,6 +2739,56 @@ fn static_sort_callback_binding( ))) } +/// Adapts boxed runtime callback arguments to a direct callback's declared ABI types. +fn adapt_direct_callback_visible_args( + ctx: &mut FunctionContext<'_>, + target_label: String, + target_param_types: &[PhpType], + visible_arg_types: Option<&[PhpType]>, + owner: &str, +) -> Result<(String, Option)> { + let Some(visible_arg_types) = visible_arg_types else { + return Ok((target_label, None)); + }; + if visible_arg_types + .iter() + .map(PhpType::codegen_repr) + .eq(target_param_types.iter().map(PhpType::codegen_repr)) + { + return Ok((target_label, None)); + } + if !visible_arg_types + .iter() + .any(|ty| matches!(ty.codegen_repr(), PhpType::Mixed | PhpType::Union(_))) + { + return Ok((target_label, None)); + } + if visible_arg_types.len() != target_param_types.len() { + return Err(CodegenIrError::unsupported(format!( + "{} with runtime callback args {:?} for direct target params {:?}", + owner, visible_arg_types, target_param_types + ))); + } + + let wrapper_label = ctx.next_label("direct_callback_arg_adapter"); + let done_label = ctx.next_label("direct_callback_after_arg_adapter"); + let wrapper = DeferredCallbackWrapper { + label: wrapper_label.clone(), + visible_arg_types: visible_arg_types.to_vec(), + target_visible_arg_types: Some(target_param_types.to_vec()), + capture_types: Vec::new(), + descriptor_prefix_types: Vec::new(), + descriptor_return_type: None, + }; + abi::emit_jump(ctx.emitter, &done_label); + crate::codegen::emit_callback_wrapper(ctx.emitter, &wrapper); + ctx.emitter.label(&done_label); + Ok(( + wrapper_label, + Some(StaticCallbackEnvSource::FunctionLabel(target_label)), + )) +} + /// Recovers a static `[class, method]` callable array as a static-method callback name. fn static_callable_array_callback_name( ctx: &FunctionContext<'_>, @@ -3205,11 +3281,12 @@ enum StaticCallbackCalledClass { } /// Source used by the sort call site to build the callback environment. -#[derive(Clone, Copy)] +#[derive(Clone)] enum StaticCallbackEnvSource { Local(LocalSlotId), ThisObject(LocalSlotId), Value(ValueId), + FunctionLabel(String), } /// Resolves a sort static-method callback, allowing `static::` with an environment. @@ -4045,6 +4122,13 @@ fn reserve_static_callback_env( ))); } } + StaticCallbackEnvSource::FunctionLabel(label) => { + abi::emit_symbol_address( + ctx.emitter, + abi::int_result_reg(ctx.emitter), + &label, + ); + } } match ctx.emitter.target.arch { Arch::AArch64 => { diff --git a/src/codegen/lower_inst/objects.rs b/src/codegen/lower_inst/objects.rs index 146b7b18b..699db8ef6 100644 --- a/src/codegen/lower_inst/objects.rs +++ b/src/codegen/lower_inst/objects.rs @@ -2666,7 +2666,6 @@ fn lower_mixed_load_prop_ref_cell( } let done_label = ctx.next_label("mixed_propref_done"); let null_label = ctx.next_label("mixed_propref_null"); - let stdclass_label = ctx.next_label("mixed_propref_stdclass"); let match_labels = candidates .iter() .map(|candidate| { @@ -2677,9 +2676,14 @@ fn lower_mixed_load_prop_ref_cell( ctx.load_value_to_reg(object, abi::int_result_reg(ctx.emitter))?; abi::emit_call_label(ctx.emitter, "__rt_mixed_unbox"); emit_mixed_object_payload_or_null(ctx, &null_label); - // stdClass has no declared reference property; route it to the null fallback. - emit_mixed_property_class_dispatch(ctx, &candidates, &match_labels, &null_label); - let _ = stdclass_label; + // stdClass and classes without this reference property have no matching cell. + emit_mixed_property_class_dispatch( + ctx, + &candidates, + &match_labels, + &null_label, + &null_label, + ); let int_reg = abi::int_result_reg(ctx.emitter); for (candidate, label) in candidates.iter().zip(match_labels.iter()) { @@ -2920,6 +2924,7 @@ fn lower_declared_mixed_prop_get( candidates: Vec, ) -> Result<()> { let null_label = ctx.next_label("mixed_prop_null"); + let miss_label = ctx.next_label("mixed_prop_miss"); let done_label = ctx.next_label("mixed_prop_done"); let stdclass_label = ctx.next_label("mixed_prop_stdclass"); let match_labels = candidates @@ -2935,7 +2940,13 @@ fn lower_declared_mixed_prop_get( ctx.load_value_to_reg(object, abi::int_result_reg(ctx.emitter))?; abi::emit_call_label(ctx.emitter, "__rt_mixed_unbox"); emit_mixed_object_payload_or_null(ctx, &null_label); - emit_mixed_property_class_dispatch(ctx, &candidates, &match_labels, &stdclass_label); + emit_mixed_property_class_dispatch( + ctx, + &candidates, + &match_labels, + &stdclass_label, + &miss_label, + ); for (candidate, label) in candidates.iter().zip(match_labels.iter()) { ctx.emitter.label(label); @@ -2952,6 +2963,11 @@ fn lower_declared_mixed_prop_get( emit_stdclass_get_from_loaded_object(ctx, property); abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&miss_label); + emit_undefined_property_warning_for_loaded_object(ctx, property); + emit_boxed_null(ctx); + abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&null_label); emit_boxed_null(ctx); @@ -3029,12 +3045,13 @@ fn emit_mixed_object_payload_or_null(ctx: &mut FunctionContext<'_>, null_label: } } -/// Emits class-id dispatch for declared property candidates and stdClass fallback. +/// Emits class-id dispatch for declared property candidates, stdClass, and a real miss branch. fn emit_mixed_property_class_dispatch( ctx: &mut FunctionContext<'_>, candidates: &[MixedPropertyCandidate], match_labels: &[String], stdclass_label: &str, + miss_label: &str, ) { match ctx.emitter.target.arch { Arch::AArch64 => { @@ -3045,6 +3062,7 @@ fn emit_mixed_property_class_dispatch( ctx.emitter.instruction(&format!("b.eq {}", label)); // read the declared property when the class id matches } emit_branch_to_stdclass_candidate(ctx, "x9", "x10", stdclass_label); + abi::emit_jump(ctx.emitter, miss_label); } Arch::X86_64 => { ctx.emitter.instruction("mov r11, QWORD PTR [rax]"); // load the receiver class id for Mixed property dispatch @@ -3054,6 +3072,7 @@ fn emit_mixed_property_class_dispatch( ctx.emitter.instruction(&format!("je {}", label)); // read the declared property when the class id matches } emit_branch_to_stdclass_candidate(ctx, "r11", "r10", stdclass_label); + abi::emit_jump(ctx.emitter, miss_label); } } } @@ -3197,6 +3216,39 @@ fn emit_property_on_null_warning(ctx: &mut FunctionContext<'_>, property: &str) abi::emit_call_label(ctx.emitter, "__rt_diag_warning"); } +/// Emits `Warning: Undefined property: Class::$name` for an object already in the result register. +fn emit_undefined_property_warning_for_loaded_object( + ctx: &mut FunctionContext<'_>, + property: &str, +) { + let (ptr_reg, len_reg) = abi::string_result_regs(ctx.emitter); + match ctx.emitter.target.arch { + Arch::AArch64 => { + ctx.emitter.instruction("ldr x9, [x0]"); // load the missing-property receiver class id + abi::emit_symbol_address(ctx.emitter, "x10", "_class_name_entries"); + ctx.emitter.instruction("lsl x11, x9, #4"); // scale the class id to the 16-byte class-name row + ctx.emitter.instruction("add x10, x10, x11"); // address the receiver's class-name metadata + ctx.emitter.instruction("ldr x1, [x10]"); // load the receiver class-name pointer + ctx.emitter.instruction("ldr x2, [x10, #8]"); // load the receiver class-name byte length + } + Arch::X86_64 => { + ctx.emitter.instruction("mov r9, QWORD PTR [rax]"); // load the missing-property receiver class id + abi::emit_symbol_address(ctx.emitter, "r10", "_class_name_entries"); + ctx.emitter.instruction("shl r9, 4"); // scale the class id to the 16-byte class-name row + ctx.emitter.instruction("mov rax, QWORD PTR [r10 + r9]"); // load the receiver class-name pointer + ctx.emitter.instruction("mov rdx, QWORD PTR [r10 + r9 + 8]"); // load the receiver class-name byte length + } + } + abi::emit_push_reg_pair(ctx.emitter, ptr_reg, len_reg); + emit_property_warning_fragment(ctx, b"Warning: Undefined property: "); + match ctx.emitter.target.arch { + Arch::AArch64 => abi::emit_pop_reg_pair(ctx.emitter, "x1", "x2"), + Arch::X86_64 => abi::emit_pop_reg_pair(ctx.emitter, "rdi", "rsi"), + } + abi::emit_call_label(ctx.emitter, "__rt_diag_warning"); + emit_property_warning_fragment(ctx, format!("::${}\n", property).as_bytes()); +} + /// Lowers a nullsafe declared-property read for nullable object receivers. pub(super) fn lower_nullsafe_prop_get( ctx: &mut FunctionContext<'_>, diff --git a/src/types/checker/builtins/callables/preg_replace_callback.rs b/src/types/checker/builtins/callables/preg_replace_callback.rs index de5c1af12..db0a0e99a 100644 --- a/src/types/checker/builtins/callables/preg_replace_callback.rs +++ b/src/types/checker/builtins/callables/preg_replace_callback.rs @@ -107,7 +107,7 @@ fn contextual_closure_sig( &format!("Closure parameter ${}", name), )?; let specialized_ty = - Checker::specialize_generic_array_hint(&declared_ty, actual_ty); + Checker::specialize_generic_array_param_hint(&declared_ty, actual_ty); (specialized_ty.clone(), specialized_ty, true) } else { (declared_ty.clone(), declared_ty, true) diff --git a/src/types/checker/functions/resolution/mod.rs b/src/types/checker/functions/resolution/mod.rs index b42cadfa0..b594b5676 100644 --- a/src/types/checker/functions/resolution/mod.rs +++ b/src/types/checker/functions/resolution/mod.rs @@ -371,7 +371,8 @@ impl Checker { arg.span, &format!("Function '{}' parameter ${}", name, param_name), )?; - let specialized_ty = Self::specialize_generic_array_hint(&declared_ty, &ty); + let specialized_ty = + Self::specialize_generic_array_param_hint(&declared_ty, &ty); param_types.push((decl.params[arg_idx].clone(), specialized_ty)); arg_idx += 1; continue; diff --git a/src/types/checker/inference/objects/methods.rs b/src/types/checker/inference/objects/methods.rs index 203d21210..d295b5e40 100644 --- a/src/types/checker/inference/objects/methods.rs +++ b/src/types/checker/inference/objects/methods.rs @@ -511,7 +511,8 @@ impl Checker { // Sharpen a declared generic `array` parameter to the call-site array // shape so method `array` params keep their associative shape, matching // how free-function `array` parameters are specialized (issue #406). - sig.params[i].1 = Self::specialize_generic_array_hint(&sig.params[i].1, arg_ty); + sig.params[i].1 = + Self::specialize_generic_array_param_hint(&sig.params[i].1, arg_ty); } if i < regular_param_count && !declared_flags.get(i).copied().unwrap_or(false) @@ -1076,7 +1077,8 @@ impl Checker { // Sharpen a declared generic `array` parameter to the call-site array // shape so static-method `array` params keep their associative shape, // matching free-function specialization (issue #406). - sig.params[i].1 = Self::specialize_generic_array_hint(&sig.params[i].1, arg_ty); + sig.params[i].1 = + Self::specialize_generic_array_param_hint(&sig.params[i].1, arg_ty); } if i < regular_param_count && !static_declared_flags.get(i).copied().unwrap_or(false) @@ -1145,7 +1147,8 @@ impl Checker { // Sharpen a declared generic `array` parameter to the call-site array // shape on `parent::`/`self::` instance dispatch, matching free-function // specialization (issue #406). - sig.params[i].1 = Self::specialize_generic_array_hint(&sig.params[i].1, arg_ty); + sig.params[i].1 = + Self::specialize_generic_array_param_hint(&sig.params[i].1, arg_ty); } if i < regular_param_count && !instance_declared_flags.get(i).copied().unwrap_or(false) diff --git a/src/types/checker/method_pass.rs b/src/types/checker/method_pass.rs index a5a7d26b8..7e2444620 100644 --- a/src/types/checker/method_pass.rs +++ b/src/types/checker/method_pass.rs @@ -85,7 +85,9 @@ impl Checker { PhpType::Array(_) | PhpType::AssocArray { .. } ) }) - .map(|t| Self::specialize_generic_array_hint(&declared, &t)) + .map(|t| { + Self::specialize_generic_array_param_hint(&declared, &t) + }) .unwrap_or(declared) } else { declared diff --git a/src/types/checker/type_compat/unions.rs b/src/types/checker/type_compat/unions.rs index 100bd13b9..c8a04250e 100644 --- a/src/types/checker/type_compat/unions.rs +++ b/src/types/checker/type_compat/unions.rs @@ -282,4 +282,42 @@ impl Checker { declared_ty.clone() } } + + /// Specializes a bare PHP `array` parameter without pinning object elements to one class. + /// + /// PHP does not expose array element generics, so a concrete object element inferred from + /// one call site is not a valid contract for later calls. Indexed object arrays therefore + /// stay `Array(Mixed)`, while associative arrays retain their key/storage shape but erase an + /// object value to `Mixed`. Scalar element types remain specialized for existing inference. + pub(crate) fn specialize_generic_array_param_hint( + declared_ty: &PhpType, + actual_ty: &PhpType, + ) -> PhpType { + if !Self::is_generic_array_hint(declared_ty) { + return declared_ty.clone(); + } + match actual_ty { + PhpType::Array(element) + if matches!(element.as_ref(), PhpType::Object(_)) + || matches!(element.as_ref(), PhpType::Union(members) if members + .iter() + .any(|member| matches!(member, PhpType::Object(_)))) => + { + PhpType::Array(Box::new(PhpType::Mixed)) + } + PhpType::AssocArray { key, value } + if matches!(value.as_ref(), PhpType::Object(_)) + || matches!(value.as_ref(), PhpType::Union(members) if members + .iter() + .any(|member| matches!(member, PhpType::Object(_)))) => + { + PhpType::AssocArray { + key: key.clone(), + value: Box::new(PhpType::Mixed), + } + } + PhpType::Array(_) | PhpType::AssocArray { .. } => actual_ty.clone(), + _ => declared_ty.clone(), + } + } } diff --git a/tests/codegen/control_flow/functions.rs b/tests/codegen/control_flow/functions.rs index 49e21bade..03471cb31 100644 --- a/tests/codegen/control_flow/functions.rs +++ b/tests/codegen/control_flow/functions.rs @@ -112,6 +112,101 @@ fn test_function_no_args() { assert_eq!(out, "42"); } +/// A bare PHP `array` parameter keeps object elements dynamically typed across sibling +/// call sites, allowing the query-builder use case without pinning the callee to one class. +#[test] +fn test_array_element_object_sibling_covariance_at_param() { + let out = compile_and_run( + " */ private array $conditions; public function __construct() { $this->conditions = []; } private function withConditions(array $conditions): self { $c = new QB(); $c->conditions = $conditions; return $c; } public function where(string $s): self { return $this->withConditions([new QCond($s)]); } public function compound(string $s): self { return $this->withConditions([new CCond($s)]); } public function size(): int { return count($this->conditions); } public function firstSql(): string { $c = $this->conditions[0]; return $c->sql; } } function main(): void { $qb = new QB(); $a = $qb->where('a = 1'); $b = $qb->compound('b = 2'); echo $a->size(), ':', $a->firstSql(), '|', $b->size(), ':', $b->firstSql(); } main();", + ); + assert_eq!(out, "1:a = 1|1:b = 2"); +} + +/// A free-function bare `array` parameter must not read a sibling object's same-offset +/// property under the first call site's class. The missing property warns and returns null. +#[test] +fn test_array_object_sibling_missing_property_does_not_read_same_offset() { + let out = compile_and_run_capture( + r#"bark; +} +echo firstBark([new BarkDog('woof')]), '|'; +$missing = firstBark([new MeowCat('mew')]); +echo $missing === null ? 'null' : $missing; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "woof|null"); + assert!( + out.stderr + .contains("Warning: Undefined property: MeowCat::$bark"), + "missing PHP-style undefined-property warning: {}", + out.stderr + ); +} + +/// An instance-method bare `array` parameter dispatches property reads by runtime class +/// when sibling objects have incompatible layouts, instead of loading a fixed offset. +#[test] +fn test_array_object_sibling_layout_mismatch_does_not_crash() { + let out = compile_and_run_capture( + r#"bark; + } +} +$reader = new BarkReader(); +echo $reader->first([new AgedDog(4, 'woof')]), '|'; +$missing = $reader->first([new ShortCat('mew')]); +echo $missing === null ? 'null' : $missing; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "woof|null"); + assert!( + out.stderr + .contains("Warning: Undefined property: ShortCat::$bark"), + "missing PHP-style undefined-property warning: {}", + out.stderr + ); +} + +/// Associative bare-array parameters erase concrete object value classes while preserving +/// hash storage, so sibling call sites remain valid and dispatch their shared property safely. +#[test] +fn test_assoc_array_object_sibling_params_preserve_hash_shape() { + let out = compile_and_run( + r#"sql; +} +echo firstSql(['first' => new LeftCondition('a = 1')]), '|'; +echo firstSql(['first' => new RightCondition('b = 2')]); +"#, + ); + assert_eq!(out, "a = 1|b = 2"); +} + // --- Logical operators --- /// EC-8 (#491): `if ($x === false) { throw; } return $x;` narrows an `int|false` value to `int`