From bcf5fedbabe4537b002a8036e91e5f36a26e8f8a Mon Sep 17 00:00:00 2001 From: Chad Peppers Date: Sat, 11 Jul 2026 09:17:58 -0500 Subject: [PATCH 1/2] feat: mixed/union values at narrower declared boundaries + typed callbacks over unknown-element arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two boundary relaxations in `types_compatible`, both runtime-enforced PHP that the checker statically rejected, plus the typed-callback fix they enable: - A `mixed` VALUE flowing into a narrower declared type (param or return) is legal PHP — the engine enforces it at runtime (TypeError on mismatch), and the boxed-Mixed representation already funnels at the boundary. Trust the declaration. - A union VALUE with at least one member the declaration accepts crosses the boundary (e.g. an `int|false` seek result passed to an `int` param on the success path). A union with NO compatible member stays rejected. `types_compatible` feeds only argument/return checks, so interface conformance and property assignment are unaffected. - Typed callbacks keep their declared contract over object/unknown-element arrays: usort/uasort/array_map fabricated an `Int` placeholder when the element type had no literal form, so a typed comparator/mapper failed ("callback parameter expects Object, got Int"). `array_element_type` now yields `Mixed` for a `Mixed` receiver; `comparator_dummy_arg_for_elem` binds a synthetic variable for Object/Mixed elements and binds `Mixed` for `Never` (empty/unknown) elements — PHP never invokes the callback for an empty array, so the declared contract stands unchecked. Byte-parity vs PHP 8.5 on all regression tests. Known limit: array_map over OBJECT-element arrays now passes the checker but still hits a pre-existing EIR lowering wall — a backend follow-up. Co-Authored-By: Claude Fable 5 --- src/builtins/array/array_map.rs | 27 +++++++++--- src/builtins/array/uasort.rs | 6 ++- src/builtins/array/usort.rs | 6 ++- src/types/checker/builtins/callables.rs | 19 ++++++-- .../checker/functions/call_validation.rs | 15 ++++--- tests/codegen/control_flow/functions.rs | 44 +++++++++++++++++++ 6 files changed, 98 insertions(+), 19 deletions(-) diff --git a/src/builtins/array/array_map.rs b/src/builtins/array/array_map.rs index 13e0e13975..644eabe3a4 100644 --- a/src/builtins/array/array_map.rs +++ b/src/builtins/array/array_map.rs @@ -48,19 +48,32 @@ fn check(cx: &mut BuiltinCheckCtx) -> Result { let arr_ty = cx.checker.infer_type(&cx.args[1], cx.env)?; match arr_ty { PhpType::Array(elem_ty) => { - let arr_ty = PhpType::Array(elem_ty.clone()); - let dummy_args = vec![ - crate::types::checker::builtins::dummy_arg_for_array_scalar_elem( - &arr_ty, cx.span, - ), - ]; + // The dummy argument mirrors the element type. Object elements (no literal form) + // and Mixed/Never elements (unknown — e.g. an `array`-hinted param or property) + // use the synthetic binding, so a TYPED callback parameter is checked against the + // real (or runtime-enforced) element type instead of a fabricated Int placeholder. + let (dummy_arg, elem_binding) = + crate::types::checker::builtins::comparator_dummy_arg_for_elem( + elem_ty.as_ref(), + cx.span, + ); + let dummy_args = vec![dummy_arg]; + let mut env_with_elem; + let cb_env: &crate::types::TypeEnv = match &elem_binding { + Some((binding_name, binding_ty)) => { + env_with_elem = cx.env.clone(); + env_with_elem.insert(binding_name.clone(), binding_ty.clone()); + &env_with_elem + } + None => cx.env, + }; let callback_ret_ty = crate::types::checker::builtins::check_callback_builtin_call( cx.checker, &cx.args[0], &dummy_args, cx.span, - cx.env, + cb_env, "array_map() callback", )?; let result_elem_ty = if callback_ret_ty == PhpType::Mixed { diff --git a/src/builtins/array/uasort.rs b/src/builtins/array/uasort.rs index f509184416..a2ace514d8 100644 --- a/src/builtins/array/uasort.rs +++ b/src/builtins/array/uasort.rs @@ -38,13 +38,15 @@ builtin! { /// /// Infers the array value element type, and validates the comparator with two dummy /// arguments of that element type. Object-element arrays use typed closure hints so -/// an unannotated comparator body (`$a <=> $b`) is checked against the real type. +/// an unannotated comparator body (`$a <=> $b`) is checked against the real type; a +/// Mixed element (unknown-element array) takes the same path so a TYPED comparator's +/// declared parameter contract stands (runtime-enforced) instead of a fabricated Int. /// Arity (exactly 2) is pre-validated by the registry. Returns `Ok(PhpType::Void)`. fn check(cx: &mut BuiltinCheckCtx) -> Result { let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; let cmp_ty = crate::types::checker::builtins::array_element_type(&arr_ty); let label = format!("{}() callback", cx.name); - if let PhpType::Object(_) = cmp_ty { + if matches!(cmp_ty, PhpType::Object(_) | PhpType::Mixed) { if let ExprKind::Closure { params, variadic, diff --git a/src/builtins/array/usort.rs b/src/builtins/array/usort.rs index 9f45cd533f..54870de572 100644 --- a/src/builtins/array/usort.rs +++ b/src/builtins/array/usort.rs @@ -38,13 +38,15 @@ builtin! { /// /// Infers the array value element type, and validates the comparator with two dummy /// arguments of that element type. Object-element arrays use typed closure hints so -/// an unannotated comparator body (`$a <=> $b`) is checked against the real type. +/// an unannotated comparator body (`$a <=> $b`) is checked against the real type; a +/// Mixed element (unknown-element array) takes the same path so a TYPED comparator's +/// declared parameter contract stands (runtime-enforced) instead of a fabricated Int. /// Arity (exactly 2) is pre-validated by the registry. Returns `Ok(PhpType::Void)`. fn check(cx: &mut BuiltinCheckCtx) -> Result { let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; let cmp_ty = crate::types::checker::builtins::array_element_type(&arr_ty); let label = format!("{}() callback", cx.name); - if let PhpType::Object(_) = cmp_ty { + if matches!(cmp_ty, PhpType::Object(_) | PhpType::Mixed) { if let ExprKind::Closure { params, variadic, diff --git a/src/types/checker/builtins/callables.rs b/src/types/checker/builtins/callables.rs index c8f40a4d80..5c02498a01 100644 --- a/src/types/checker/builtins/callables.rs +++ b/src/types/checker/builtins/callables.rs @@ -134,12 +134,16 @@ pub(crate) fn dummy_arg_for_array_scalar_elem(arr_ty: &PhpType, span: crate::spa /// Returns the element type carried by an array/associative-array type. /// -/// Falls back to `Int` for non-array types so callers can build a placeholder -/// comparator argument without special-casing every caller. +/// A `Mixed` receiver (e.g. an `array`-hinted property whose element type only phpdoc knows) +/// yields `Mixed` elements, so a typed callback's declared parameter contract stands instead of +/// being checked against a fabricated placeholder. Falls back to `Int` for other non-array +/// types so callers can build a placeholder comparator argument without special-casing every +/// caller. pub(crate) fn array_element_type(arr_ty: &PhpType) -> PhpType { match arr_ty { PhpType::Array(elem_ty) => (**elem_ty).clone(), PhpType::AssocArray { value, .. } => (**value).clone(), + PhpType::Mixed => PhpType::Mixed, _ => PhpType::Int, } } @@ -168,10 +172,19 @@ pub(crate) fn comparator_dummy_arg_for_elem( PhpType::Bool | PhpType::False => { (Expr::new(ExprKind::BoolLiteral(false), span), None) } - PhpType::Object(_) => ( + // Object and Mixed elements have no literal form: a reserved synthetic variable is + // bound to the element type so a typed callback parameter is checked against the real + // (or unknown-and-runtime-enforced) type instead of a fabricated Int placeholder. + PhpType::Object(_) | PhpType::Mixed => ( Expr::new(ExprKind::Variable(COMPARATOR_ELEM_PLACEHOLDER.to_string()), span), Some((COMPARATOR_ELEM_PLACEHOLDER.to_string(), elem_ty.clone())), ), + // A Never element (empty/unknown array) binds Mixed: the callback is never invoked at + // runtime for an empty array, so its declared parameter contract must stand unchecked. + PhpType::Never => ( + Expr::new(ExprKind::Variable(COMPARATOR_ELEM_PLACEHOLDER.to_string()), span), + Some((COMPARATOR_ELEM_PLACEHOLDER.to_string(), PhpType::Mixed)), + ), _ => (Expr::new(ExprKind::IntLiteral(0), span), None), } } diff --git a/src/types/checker/functions/call_validation.rs b/src/types/checker/functions/call_validation.rs index 9ed0c0a682..bc2bb123aa 100644 --- a/src/types/checker/functions/call_validation.rs +++ b/src/types/checker/functions/call_validation.rs @@ -184,13 +184,18 @@ impl Checker { } match (expected, actual) { (PhpType::Mixed, _) => true, + // A `mixed` VALUE flowing into a narrower declared type is legal PHP — the engine + // enforces it at runtime (TypeError on mismatch). Trust the declaration, matching + // the runtime's boxed-Mixed representation funnels at the boundary. + (_, PhpType::Mixed) => true, (_, PhpType::Never) => true, // never is the bottom type — compatible with any expected type (PhpType::Bool, PhpType::False) => true, - // PHP coercive mode: scalar parameters accept Mixed values with a - // runtime narrowing cast. - // This is needed because non-constant `int + int` overflows to float, - // making the result Mixed even when both operands are statically Int. - (PhpType::Int | PhpType::Float | PhpType::Bool | PhpType::Str, PhpType::Mixed) => true, + // A union VALUE with at least one member the declaration accepts is likewise + // runtime-enforced PHP (e.g. an `int|false` seek result passed to an `int` param on + // the success path). A union with NO compatible member stays rejected. + (_, PhpType::Union(members)) if !matches!(expected, PhpType::Union(_)) => { + members.iter().any(|m| Self::types_compatible(expected, m)) + } (PhpType::Iterable, PhpType::Array(_) | PhpType::AssocArray { .. } | PhpType::Iterable) => true, (PhpType::Union(expected_members), PhpType::Union(actual_members)) => actual_members .iter() diff --git a/tests/codegen/control_flow/functions.rs b/tests/codegen/control_flow/functions.rs index 799ca30a02..516ec02db2 100644 --- a/tests/codegen/control_flow/functions.rs +++ b/tests/codegen/control_flow/functions.rs @@ -167,3 +167,47 @@ fn test_property_throw_guard_narrowing() { ); assert_eq!(out, "x"); } + +/// A `mixed` VALUE flowing into a narrower declared boundary is legal PHP (the engine enforces +/// at runtime) — a mixed param passed on to a `string` param and a mixed value returned as a +/// declared `int` both compile and run byte-identically (the boxed-Mixed representation +/// funnels at the boundary). +#[test] +fn test_mixed_value_into_typed_boundary() { + let out = compile_and_run( + "name; } function firstMode(string $mode, array $allowed): bool { return in_array($mode, $allowed); } function main(): void { $meta = ['mode' => 'r+', 'seekable' => true]; $mode = $meta['mode']; $ok = firstMode($mode, ['r+', 'w+']) ? 'ok' : 'no'; $items = [new Item('a'), new Item('b')]; $x = $items[1]; echo $ok, ':', label($x), ':', strtoupper($mode); } main();", + ); + assert_eq!(out, "ok:b:R+"); +} + +/// A typed comparator over an `array`-hinted parameter keeps its declared parameter contract — +/// usort checks the closure against its own declarations (via the element-type binding) +/// instead of a fabricated Int placeholder. Byte-parity vs PHP 8.5. +#[test] +fn test_typed_callback_over_array_hinted_param() { + let out = compile_and_run( + " $a->n <=> $b->n); $out = ''; foreach ($items as $b) { $out .= $b->n; } return $out; } function main(): void { echo sorted([new Box(3), new Box(1), new Box(2)]); } main();", + ); + assert_eq!(out, "123"); +} From 1e0aac9008095c582e3865ca8c760a3677d1e3cc Mon Sep 17 00:00:00 2001 From: Vincenzo Petrucci Date: Mon, 20 Jul 2026 09:17:40 +0200 Subject: [PATCH 2/2] fix(types): validate mixed callback boundaries soundly --- docs/php/arrays.md | 1 + src/builtins/array/array_all.rs | 20 +- src/builtins/array/array_any.rs | 20 +- src/builtins/array/array_filter.rs | 24 +-- src/builtins/array/array_find.rs | 20 +- src/builtins/array/array_map.rs | 38 ++-- src/builtins/array/array_reduce.rs | 25 +-- src/builtins/array/array_udiff.rs | 26 ++- src/builtins/array/array_uintersect.rs | 26 ++- src/builtins/array/array_walk.rs | 18 +- src/builtins/array/array_walk_recursive.rs | 18 +- src/builtins/array/uasort.rs | 84 ++------ src/builtins/array/usort.rs | 84 ++------ src/builtins/io/fseek.rs | 11 +- src/types/checker/builtins/callables.rs | 196 ++++++++++++------ src/types/checker/builtins/mod.rs | 6 +- src/types/checker/callables/closures.rs | 99 ++++----- .../checker/functions/call_validation.rs | 14 +- tests/codegen/arrays/callbacks.rs | 56 +++++ tests/codegen/control_flow/functions.rs | 20 +- tests/error_tests/array_builtins.rs | 18 ++ tests/error_tests/type_system.rs | 18 ++ 22 files changed, 410 insertions(+), 432 deletions(-) diff --git a/docs/php/arrays.md b/docs/php/arrays.md index 35f246ae75..de266d6ee4 100644 --- a/docs/php/arrays.md +++ b/docs/php/arrays.md @@ -280,6 +280,7 @@ PHP does not allow keyed and unkeyed entries in the same destructuring pattern, `array_filter()` accepts `ARRAY_FILTER_USE_VALUE` (`0`), `ARRAY_FILTER_USE_BOTH` (`1`), and `ARRAY_FILTER_USE_KEY` (`2`). Invalid mode values throw `ValueError`. > Callback arguments can be string literals, runtime string names for user functions, first-class callable values, anonymous functions, arrow functions, or variables holding captured closures. `array_map()`, `array_filter()`, `array_reduce()`, `array_walk()`, `usort()`, `uksort()`, and `uasort()` resolve runtime string callback variables through descriptor dispatch. `array_map()` stores mixed result elements when the selected callback return shape is only known at runtime. `array_map()` also runs over a heterogeneous (boxed `mixed`) input array: each element is passed to the callback as a `mixed` value, so a callback with a `mixed` (or untyped) parameter sees and can return each element with its original runtime type. +> When a parameter is declared only as `array`, its element type is initially unknown. Array-callback checking preserves explicit callback parameter declarations and uses them to type the closure body instead of fabricating an `int` element. Known element types are still checked normally, and this contextual rule does not make `mixed` globally compatible with object, array, or other refcounted declarations. `array_map()` currently rejects known object-element arrays because its callback runtime does not yet support that input layout. > `call_user_func_array()` also accepts dynamic indexed and associative argument arrays for callbacks with a known signature, including userland variadic callbacks. When a callable value has no single static signature at the call site, elephc emits an AOT runtime dispatch over user functions and closure/FCC wrappers available in that codegen context, then applies the matched target's descriptor metadata: parameter names, defaults, by-reference flags, variadic position, return shape, captures, hidden receiver arguments, and callable shape. Runtime string callback names dispatch over user functions, supported builtins, and public static-method strings by case-insensitive name matching, materialize the matched descriptor, and invoke its generated descriptor invoker. Descriptor invokers receive a temporary boxed Mixed clone of the argument container and inspect its runtime tag to handle indexed arrays and associative hashes through the same signature-level wrapper, so the source `$args` remains usable with its original static layout after the call. String keys bind named parameters; unconsumed string and numeric keys are copied into `...$rest` for variadic callbacks. Dynamic arrays passed to by-reference callback parameters use temporary reference cells, so callback writes do not mutate the source argument array. `usort()` and `uasort()` sort arrays of **objects** as well as scalars. The comparator receives each element as its object handle, so an unannotated comparator's parameters are typed from the array element automatically — `usort($items, fn($a, $b) => $a->weight <=> $b->weight)` works without writing `($a, $b)` type hints, and `usort($dates, fn($a, $b) => $a <=> $b)` over `DateTime`/`DateTimeImmutable` compares by instant. Explicit hints (`function (Item $a, Item $b)`) are equally accepted. Sorting an array of **strings** with a user comparator is not yet supported and reports a clear unsupported-feature error. diff --git a/src/builtins/array/array_all.rs b/src/builtins/array/array_all.rs index 1085840cc2..d1b6e78bf8 100644 --- a/src/builtins/array/array_all.rs +++ b/src/builtins/array/array_all.rs @@ -9,7 +9,7 @@ //! - The PHP golden signature is `fixed(&["array","callback"])` (exactly 2 required params). //! The legacy CHECK arm also required exactly 2 arguments; no arity override is needed. //! - `check` validates the first argument is an indexed array and validates the predicate -//! callback with a dummy element argument. Returns `PhpType::Bool`. +//! callback with its contextual element type. Returns `PhpType::Bool`. //! - `lower` is a thin wrapper over the shared `arrays::lower_array_all` emitter. use crate::builtins::spec::BuiltinCheckCtx; @@ -25,6 +25,7 @@ builtin! { params: [array: Mixed, callback: Mixed], returns: Bool, check: check, + lazy_check: true, lower: lower, summary: "Returns true when every array element satisfies the predicate callback.", php_manual: "https://www.php.net/manual/en/function.array-all.php", @@ -32,13 +33,10 @@ builtin! { /// Validates the predicate callback for an `array_all` call and returns `PhpType::Bool`. /// -/// The first argument must be an indexed array. The callback is validated with a single -/// dummy element argument derived from the array element type. Arity (exactly 2 args) is +/// The first argument must be an indexed array. The callback is validated with the array +/// element type as context. Arity (exactly 2 args) is /// pre-validated by `check_arity`. fn check(cx: &mut BuiltinCheckCtx) -> Result { - for arg in cx.args { - cx.checker.infer_type(arg, cx.env)?; - } let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; if !matches!(arr_ty, PhpType::Array(_)) { return Err(CompileError::new( @@ -46,16 +44,12 @@ fn check(cx: &mut BuiltinCheckCtx) -> Result { &format!("{}() first argument must be array", cx.name), )); } - let dummy_args = vec![ - crate::types::checker::builtins::dummy_arg_for_array_scalar_elem( - &arr_ty, cx.span, - ), - ]; + let callback_arg_types = [crate::types::checker::builtins::array_element_type(&arr_ty)]; let label = format!("{}() callback", cx.name); - crate::types::checker::builtins::check_callback_builtin_call( + crate::types::checker::builtins::check_array_callback_builtin_call( cx.checker, &cx.args[1], - &dummy_args, + &callback_arg_types, cx.span, cx.env, &label, diff --git a/src/builtins/array/array_any.rs b/src/builtins/array/array_any.rs index 1910def53e..06bc16b642 100644 --- a/src/builtins/array/array_any.rs +++ b/src/builtins/array/array_any.rs @@ -9,7 +9,7 @@ //! - The PHP golden signature is `fixed(&["array","callback"])` (exactly 2 required params). //! The legacy CHECK arm also required exactly 2 arguments; no arity override is needed. //! - `check` validates the first argument is an indexed array and validates the predicate -//! callback with a dummy element argument. Returns `PhpType::Bool`. +//! callback with its contextual element type. Returns `PhpType::Bool`. //! - `lower` is a thin wrapper over the shared `arrays::lower_array_any` emitter. use crate::builtins::spec::BuiltinCheckCtx; @@ -25,6 +25,7 @@ builtin! { params: [array: Mixed, callback: Mixed], returns: Bool, check: check, + lazy_check: true, lower: lower, summary: "Returns true when at least one array element satisfies the predicate callback.", php_manual: "https://www.php.net/manual/en/function.array-any.php", @@ -32,13 +33,10 @@ builtin! { /// Validates the predicate callback for an `array_any` call and returns `PhpType::Bool`. /// -/// The first argument must be an indexed array. The callback is validated with a single -/// dummy element argument derived from the array element type. Arity (exactly 2 args) is +/// The first argument must be an indexed array. The callback is validated with the array +/// element type as context. Arity (exactly 2 args) is /// pre-validated by `check_arity`. fn check(cx: &mut BuiltinCheckCtx) -> Result { - for arg in cx.args { - cx.checker.infer_type(arg, cx.env)?; - } let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; if !matches!(arr_ty, PhpType::Array(_)) { return Err(CompileError::new( @@ -46,16 +44,12 @@ fn check(cx: &mut BuiltinCheckCtx) -> Result { &format!("{}() first argument must be array", cx.name), )); } - let dummy_args = vec![ - crate::types::checker::builtins::dummy_arg_for_array_scalar_elem( - &arr_ty, cx.span, - ), - ]; + let callback_arg_types = [crate::types::checker::builtins::array_element_type(&arr_ty)]; let label = format!("{}() callback", cx.name); - crate::types::checker::builtins::check_callback_builtin_call( + crate::types::checker::builtins::check_array_callback_builtin_call( cx.checker, &cx.args[1], - &dummy_args, + &callback_arg_types, cx.span, cx.env, &label, diff --git a/src/builtins/array/array_filter.rs b/src/builtins/array/array_filter.rs index 2e2c67a6bd..3eee6e8091 100644 --- a/src/builtins/array/array_filter.rs +++ b/src/builtins/array/array_filter.rs @@ -10,8 +10,8 @@ //! The legacy CHECK arm required 2 or 3 arguments (`args.len() < 2 || args.len() > 3`), //! so `min_args: 2` reproduces that enforcement in `check_arity`; the derived max of 3 //! from the optional signature already matches. -//! - `check` validates the first argument is an indexed array, builds callback dummy args -//! based on the static mode value, and validates the callback signature. The return type +//! - `check` validates the first argument is an indexed array, derives callback argument types +//! from the static mode value, and validates the callback signature. The return type //! preserves the input array element type. //! - `lower` is a thin wrapper over the shared `arrays::lower_array_filter` emitter. @@ -29,6 +29,7 @@ builtin! { min_args: 2, returns: Mixed, check: check, + lazy_check: true, lower: lower, summary: "Filters elements of an array using a callback function.", php_manual: "https://www.php.net/manual/en/function.array-filter.php", @@ -36,27 +37,26 @@ builtin! { /// Returns the filtered array type for an `array_filter` call. /// -/// Validates the first argument is an indexed array, builds the callback dummy args -/// based on the optional mode argument, and validates the callback. Arity (2 or 3 args) +/// Validates the first argument is an indexed array, derives callback argument types +/// from the optional mode argument, and validates the callback. Arity (2 or 3 args) /// is pre-validated by `check_arity`. fn check(cx: &mut BuiltinCheckCtx) -> Result { - for arg in cx.args { - cx.checker.infer_type(arg, cx.env)?; - } let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; + if let Some(mode) = cx.args.get(2) { + cx.checker.infer_type(mode, cx.env)?; + } match arr_ty { PhpType::Array(elem_ty) => { let arr_ty = PhpType::Array(elem_ty.clone()); - let dummy_args = - crate::types::checker::builtins::array_filter_callback_dummy_args( + let callback_arg_types = + crate::types::checker::builtins::array_filter_callback_arg_types( &arr_ty, cx.args.get(2), - cx.span, ); - crate::types::checker::builtins::check_callback_builtin_call( + crate::types::checker::builtins::check_array_callback_builtin_call( cx.checker, &cx.args[1], - &dummy_args, + &callback_arg_types, cx.span, cx.env, "array_filter() callback", diff --git a/src/builtins/array/array_find.rs b/src/builtins/array/array_find.rs index 50dbaef94f..ea7df0c0e4 100644 --- a/src/builtins/array/array_find.rs +++ b/src/builtins/array/array_find.rs @@ -9,7 +9,7 @@ //! - The PHP golden signature is `fixed(&["array","callback"])` (exactly 2 required params). //! The legacy CHECK arm also required exactly 2 arguments; no arity override is needed. //! - `check` validates the first argument is an indexed array and validates the predicate -//! callback with a dummy element argument. Returns `PhpType::Mixed` (the matching element +//! callback with its contextual element type. Returns `PhpType::Mixed` (the matching element //! or null). //! - `lower` is a thin wrapper over the shared `arrays::lower_array_find` emitter. @@ -26,6 +26,7 @@ builtin! { params: [array: Mixed, callback: Mixed], returns: Mixed, check: check, + lazy_check: true, lower: lower, summary: "Returns the first element satisfying a predicate callback, or null.", php_manual: "https://www.php.net/manual/en/function.array-find.php", @@ -33,13 +34,10 @@ builtin! { /// Validates the predicate callback for an `array_find` call and returns `PhpType::Mixed`. /// -/// The first argument must be an indexed array. The callback is validated with a single -/// dummy element argument derived from the array element type. Arity (exactly 2 args) is +/// The first argument must be an indexed array. The callback is validated with the array +/// element type as context. Arity (exactly 2 args) is /// pre-validated by `check_arity`. fn check(cx: &mut BuiltinCheckCtx) -> Result { - for arg in cx.args { - cx.checker.infer_type(arg, cx.env)?; - } let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; if !matches!(arr_ty, PhpType::Array(_)) { return Err(CompileError::new( @@ -47,16 +45,12 @@ fn check(cx: &mut BuiltinCheckCtx) -> Result { &format!("{}() first argument must be array", cx.name), )); } - let dummy_args = vec![ - crate::types::checker::builtins::dummy_arg_for_array_scalar_elem( - &arr_ty, cx.span, - ), - ]; + let callback_arg_types = [crate::types::checker::builtins::array_element_type(&arr_ty)]; let label = format!("{}() callback", cx.name); - crate::types::checker::builtins::check_callback_builtin_call( + crate::types::checker::builtins::check_array_callback_builtin_call( cx.checker, &cx.args[1], - &dummy_args, + &callback_arg_types, cx.span, cx.env, &label, diff --git a/src/builtins/array/array_map.rs b/src/builtins/array/array_map.rs index 644eabe3a4..01403711dc 100644 --- a/src/builtins/array/array_map.rs +++ b/src/builtins/array/array_map.rs @@ -31,6 +31,7 @@ builtin! { max_args: 2, returns: Mixed, check: check, + lazy_check: true, lower: lower, summary: "Applies a callback to the elements of an array.", php_manual: "https://www.php.net/manual/en/function.array-map.php", @@ -39,41 +40,26 @@ builtin! { /// Returns the mapped array type for an `array_map` call. /// /// Validates that the second argument is an indexed array, checks the callback -/// with a dummy element argument, and derives the result element type from the -/// callback return type. Arity (exactly 2 args) is pre-validated by `check_arity`. +/// with its contextual element type, and derives the result element type from the callback +/// return type. Arity (exactly 2 args) is pre-validated by `check_arity`. fn check(cx: &mut BuiltinCheckCtx) -> Result { - for arg in cx.args { - cx.checker.infer_type(arg, cx.env)?; - } let arr_ty = cx.checker.infer_type(&cx.args[1], cx.env)?; match arr_ty { PhpType::Array(elem_ty) => { - // The dummy argument mirrors the element type. Object elements (no literal form) - // and Mixed/Never elements (unknown — e.g. an `array`-hinted param or property) - // use the synthetic binding, so a TYPED callback parameter is checked against the - // real (or runtime-enforced) element type instead of a fabricated Int placeholder. - let (dummy_arg, elem_binding) = - crate::types::checker::builtins::comparator_dummy_arg_for_elem( - elem_ty.as_ref(), + if matches!(elem_ty.as_ref(), PhpType::Object(_)) { + return Err(CompileError::new( cx.span, - ); - let dummy_args = vec![dummy_arg]; - let mut env_with_elem; - let cb_env: &crate::types::TypeEnv = match &elem_binding { - Some((binding_name, binding_ty)) => { - env_with_elem = cx.env.clone(); - env_with_elem.insert(binding_name.clone(), binding_ty.clone()); - &env_with_elem - } - None => cx.env, - }; + "array_map() does not yet support object array elements", + )); + } + let callback_arg_types = [elem_ty.as_ref().clone()]; let callback_ret_ty = - crate::types::checker::builtins::check_callback_builtin_call( + crate::types::checker::builtins::check_array_callback_builtin_call( cx.checker, &cx.args[0], - &dummy_args, + &callback_arg_types, cx.span, - cb_env, + cx.env, "array_map() callback", )?; let result_elem_ty = if callback_ret_ty == PhpType::Mixed { diff --git a/src/builtins/array/array_reduce.rs b/src/builtins/array/array_reduce.rs index 55b7110ff5..3735d83995 100644 --- a/src/builtins/array/array_reduce.rs +++ b/src/builtins/array/array_reduce.rs @@ -9,8 +9,8 @@ //! - The PHP golden signature is `optional(&["array","callback","initial"], 2, &[null])`. //! The legacy CHECK arm required exactly 3 arguments, so `min_args: 3, max_args: 3` //! reproduce that enforcement in `check_arity` only. -//! - `check` validates the callback with a two-element dummy args list (carry=int literal, -//! element=array element dummy). The return type is `PhpType::Int`, matching the legacy arm. +//! - `check` validates the callback with the inferred initial and array-element types. +//! The return type is `PhpType::Int`, matching the legacy arm. //! - `lower` is a thin wrapper over the shared `arrays::lower_array_reduce` emitter. use crate::builtins::spec::{BuiltinCheckCtx, DefaultSpec}; @@ -18,7 +18,6 @@ use crate::codegen::context::FunctionContext; use crate::codegen::CodegenIrError; use crate::errors::CompileError; use crate::ir::Instruction; -use crate::parser::ast::{Expr, ExprKind}; use crate::types::PhpType; builtin! { @@ -29,6 +28,7 @@ builtin! { max_args: 3, returns: Mixed, check: check, + lazy_check: true, lower: lower, summary: "Iteratively reduces an array to a single value using a callback function.", php_manual: "https://www.php.net/manual/en/function.array-reduce.php", @@ -36,24 +36,19 @@ builtin! { /// Validates the callback for an `array_reduce` call and returns `PhpType::Int`. /// -/// Builds a two-element dummy args list: an integer literal as the carry placeholder -/// and a scalar element placeholder derived from the first-argument array type. +/// Uses the initial-value and array-element types as the two callback parameter contexts. /// Arity (exactly 3 args) is pre-validated by `check_arity`. fn check(cx: &mut BuiltinCheckCtx) -> Result { - for arg in cx.args { - cx.checker.infer_type(arg, cx.env)?; - } let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; - let dummy_args = vec![ - Expr::new(ExprKind::IntLiteral(0), cx.span), - crate::types::checker::builtins::dummy_arg_for_array_scalar_elem( - &arr_ty, cx.span, - ), + let initial_ty = cx.checker.infer_type(&cx.args[2], cx.env)?; + let callback_arg_types = [ + initial_ty, + crate::types::checker::builtins::array_element_type(&arr_ty), ]; - crate::types::checker::builtins::check_callback_builtin_call( + crate::types::checker::builtins::check_array_callback_builtin_call( cx.checker, &cx.args[1], - &dummy_args, + &callback_arg_types, cx.span, cx.env, "array_reduce() callback", diff --git a/src/builtins/array/array_udiff.rs b/src/builtins/array/array_udiff.rs index 606b89abcd..d934084e95 100644 --- a/src/builtins/array/array_udiff.rs +++ b/src/builtins/array/array_udiff.rs @@ -9,8 +9,8 @@ //! - The PHP golden signature is `fixed(&["array1","array2","callback"])` (exactly 3 //! required params). The legacy CHECK arm also required exactly 3 arguments; no arity //! override is needed. -//! - `check` validates the first argument is an indexed array, builds a two-element -//! comparator dummy args list (one per array element), and validates the comparator +//! - `check` validates the first argument is an indexed array, derives one contextual +//! comparator type from each input array, and validates the comparator //! callback. Returns the first-argument array type. //! - `lower` is a thin wrapper over the shared `arrays::lower_array_udiff` emitter. @@ -27,6 +27,7 @@ builtin! { params: [array1: Mixed, array2: Mixed, callback: Mixed], returns: Mixed, check: check, + lazy_check: true, lower: lower, summary: "Computes the difference of arrays using a callback comparator.", php_manual: "https://www.php.net/manual/en/function.array-udiff.php", @@ -34,13 +35,10 @@ builtin! { /// Validates the comparator callback for an `array_udiff` call and returns the first-array type. /// -/// The first argument must be an indexed array. The comparator is validated with two dummy -/// element arguments (one per array element). Arity (exactly 3 args) is pre-validated by +/// The first argument must be an indexed array. The comparator is validated with one +/// contextual element type per input array. Arity (exactly 3 args) is pre-validated by /// `check_arity`. fn check(cx: &mut BuiltinCheckCtx) -> Result { - for arg in cx.args { - cx.checker.infer_type(arg, cx.env)?; - } let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; if !matches!(arr_ty, PhpType::Array(_)) { return Err(CompileError::new( @@ -48,16 +46,16 @@ fn check(cx: &mut BuiltinCheckCtx) -> Result { &format!("{}() first argument must be array", cx.name), )); } - let cmp_arg = - crate::types::checker::builtins::dummy_arg_for_array_scalar_elem( - &arr_ty, cx.span, - ); - let dummy_args = vec![cmp_arg.clone(), cmp_arg]; + let second_arr_ty = cx.checker.infer_type(&cx.args[1], cx.env)?; + let callback_arg_types = [ + crate::types::checker::builtins::array_element_type(&arr_ty), + crate::types::checker::builtins::array_element_type(&second_arr_ty), + ]; let label = format!("{}() comparator", cx.name); - crate::types::checker::builtins::check_callback_builtin_call( + crate::types::checker::builtins::check_array_callback_builtin_call( cx.checker, &cx.args[2], - &dummy_args, + &callback_arg_types, cx.span, cx.env, &label, diff --git a/src/builtins/array/array_uintersect.rs b/src/builtins/array/array_uintersect.rs index 0825de506f..d537801987 100644 --- a/src/builtins/array/array_uintersect.rs +++ b/src/builtins/array/array_uintersect.rs @@ -9,8 +9,8 @@ //! - The PHP golden signature is `fixed(&["array1","array2","callback"])` (exactly 3 //! required params). The legacy CHECK arm also required exactly 3 arguments; no arity //! override is needed. -//! - `check` validates the first argument is an indexed array, builds a two-element -//! comparator dummy args list (one per array element), and validates the comparator +//! - `check` validates the first argument is an indexed array, derives one contextual +//! comparator type from each input array, and validates the comparator //! callback. Returns the first-argument array type. //! - `lower` is a thin wrapper over the shared `arrays::lower_array_uintersect` emitter. @@ -27,6 +27,7 @@ builtin! { params: [array1: Mixed, array2: Mixed, callback: Mixed], returns: Mixed, check: check, + lazy_check: true, lower: lower, summary: "Computes the intersection of arrays using a callback comparator.", php_manual: "https://www.php.net/manual/en/function.array-uintersect.php", @@ -34,13 +35,10 @@ builtin! { /// Validates the comparator callback for an `array_uintersect` call and returns the first-array type. /// -/// The first argument must be an indexed array. The comparator is validated with two dummy -/// element arguments (one per array element). Arity (exactly 3 args) is pre-validated by +/// The first argument must be an indexed array. The comparator is validated with one +/// contextual element type per input array. Arity (exactly 3 args) is pre-validated by /// `check_arity`. fn check(cx: &mut BuiltinCheckCtx) -> Result { - for arg in cx.args { - cx.checker.infer_type(arg, cx.env)?; - } let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; if !matches!(arr_ty, PhpType::Array(_)) { return Err(CompileError::new( @@ -48,16 +46,16 @@ fn check(cx: &mut BuiltinCheckCtx) -> Result { &format!("{}() first argument must be array", cx.name), )); } - let cmp_arg = - crate::types::checker::builtins::dummy_arg_for_array_scalar_elem( - &arr_ty, cx.span, - ); - let dummy_args = vec![cmp_arg.clone(), cmp_arg]; + let second_arr_ty = cx.checker.infer_type(&cx.args[1], cx.env)?; + let callback_arg_types = [ + crate::types::checker::builtins::array_element_type(&arr_ty), + crate::types::checker::builtins::array_element_type(&second_arr_ty), + ]; let label = format!("{}() comparator", cx.name); - crate::types::checker::builtins::check_callback_builtin_call( + crate::types::checker::builtins::check_array_callback_builtin_call( cx.checker, &cx.args[2], - &dummy_args, + &callback_arg_types, cx.span, cx.env, &label, diff --git a/src/builtins/array/array_walk.rs b/src/builtins/array/array_walk.rs index ab1526d4b7..92876f4bab 100644 --- a/src/builtins/array/array_walk.rs +++ b/src/builtins/array/array_walk.rs @@ -9,8 +9,7 @@ //! - The golden signature is `first_param_ref(fixed(["array", "callback"]))`: exactly 2 //! arguments, the `array` param is by-reference. The `ref` marker drives in-place //! mutation (ir_lower reads `ref_params` from the registry sig). -//! - `check` validates the array and callback arguments: infers the array element type, -//! builds a dummy element argument for the callback, and validates the callback. +//! - `check` validates the array and callback arguments using the contextual element type. //! Returns `Void`. //! - `lower` is a thin wrapper over the shared `arrays::lower_array_walk` emitter. @@ -27,6 +26,7 @@ builtin! { params: [ref array: Mixed, callback: Mixed], returns: Void, check: check, + lazy_check: true, lower: lower, summary: "Applies a user function to every member of an array.", php_manual: "https://www.php.net/manual/en/function.array-walk.php", @@ -34,21 +34,15 @@ builtin! { /// Validates the array and callback arguments for an `array_walk` call. /// -/// Infers each argument, derives the element type from the array, builds a single -/// dummy element argument for callback validation, and checks the callback signature. +/// Infers the array, derives its element type, and checks the callback signature contextually. /// Arity (exactly 2) is pre-validated by the registry. Returns `Ok(PhpType::Void)`. fn check(cx: &mut BuiltinCheckCtx) -> Result { - for arg in cx.args { - cx.checker.infer_type(arg, cx.env)?; - } let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; - let dummy_args = vec![ - crate::types::checker::builtins::dummy_arg_for_array_scalar_elem(&arr_ty, cx.span), - ]; - crate::types::checker::builtins::check_callback_builtin_call( + let callback_arg_types = [crate::types::checker::builtins::array_element_type(&arr_ty)]; + crate::types::checker::builtins::check_array_callback_builtin_call( cx.checker, &cx.args[1], - &dummy_args, + &callback_arg_types, cx.span, cx.env, &format!("{}() callback", cx.name), diff --git a/src/builtins/array/array_walk_recursive.rs b/src/builtins/array/array_walk_recursive.rs index 9317a0671f..51c39b2855 100644 --- a/src/builtins/array/array_walk_recursive.rs +++ b/src/builtins/array/array_walk_recursive.rs @@ -9,8 +9,7 @@ //! - The golden signature is `first_param_ref(fixed(["array", "callback"]))`: exactly 2 //! arguments, the `array` param is by-reference. The `ref` marker drives in-place //! mutation (ir_lower reads `ref_params` from the registry sig). -//! - `check` validates the array and callback arguments: infers the array element type, -//! builds a dummy element argument for the callback, and validates the callback. +//! - `check` validates the array and callback arguments using the contextual element type. //! Returns `Void`. //! - `lower` is a thin wrapper over the shared `arrays::lower_array_walk_recursive` emitter. @@ -27,6 +26,7 @@ builtin! { params: [ref array: Mixed, callback: Mixed], returns: Void, check: check, + lazy_check: true, lower: lower, summary: "Applies a user function recursively to every member of an array.", php_manual: "https://www.php.net/manual/en/function.array-walk-recursive.php", @@ -34,21 +34,15 @@ builtin! { /// Validates the array and callback arguments for an `array_walk_recursive` call. /// -/// Infers each argument, derives the element type from the array, builds a single -/// dummy element argument for callback validation, and checks the callback signature. +/// Infers the array, derives its element type, and checks the callback signature contextually. /// Arity (exactly 2) is pre-validated by the registry. Returns `Ok(PhpType::Void)`. fn check(cx: &mut BuiltinCheckCtx) -> Result { - for arg in cx.args { - cx.checker.infer_type(arg, cx.env)?; - } let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; - let dummy_args = vec![ - crate::types::checker::builtins::dummy_arg_for_array_scalar_elem(&arr_ty, cx.span), - ]; - crate::types::checker::builtins::check_callback_builtin_call( + let callback_arg_types = [crate::types::checker::builtins::array_element_type(&arr_ty)]; + crate::types::checker::builtins::check_array_callback_builtin_call( cx.checker, &cx.args[1], - &dummy_args, + &callback_arg_types, cx.span, cx.env, &format!("{}() callback", cx.name), diff --git a/src/builtins/array/uasort.rs b/src/builtins/array/uasort.rs index 6484b6035b..ca7ca28e79 100644 --- a/src/builtins/array/uasort.rs +++ b/src/builtins/array/uasort.rs @@ -9,9 +9,8 @@ //! - The golden signature is `first_param_ref(fixed(["array", "callback"]))`: exactly 2 //! arguments, the `array` param is by-reference. The `ref` marker drives in-place //! mutation (ir_lower reads `ref_params` from the registry sig). -//! - `check` derives the comparator element type from the array value type, validates the -//! callback with two dummy element arguments (comparator receives two values), and handles -//! object-element arrays with typed closure hints. Returns `Void`. +//! - `check` derives the comparator element type from the array value type and validates both +//! callback parameters contextually, including object and opaque element types. Returns `Void`. //! - `lower` is a thin wrapper over the shared `arrays::lower_uasort` emitter. use crate::builtins::spec::BuiltinCheckCtx; @@ -19,7 +18,6 @@ use crate::codegen::context::FunctionContext; use crate::codegen::CodegenIrError; use crate::errors::CompileError; use crate::ir::Instruction; -use crate::parser::ast::ExprKind; use crate::types::PhpType; builtin! { @@ -36,77 +34,23 @@ builtin! { /// Validates the array and comparator callback arguments for a `uasort` call. /// -/// Infers the array value element type, and validates the comparator with two dummy -/// arguments of that element type. Object-element arrays use typed closure hints so -/// an unannotated comparator body (`$a <=> $b`) is checked against the real type; a -/// Mixed element (unknown-element array) takes the same path so a TYPED comparator's -/// declared parameter contract stands (runtime-enforced) instead of a fabricated Int. +/// Infers the array value element type and validates both comparator arguments with that type. +/// Closure bodies receive contextual hints for unannotated +/// parameters, while `Mixed`/`Never` elements leave explicit declarations authoritative. /// Arity (exactly 2) is pre-validated by the registry. Returns `Ok(PhpType::Void)`. fn check(cx: &mut BuiltinCheckCtx) -> Result { let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; let cmp_ty = crate::types::checker::builtins::array_element_type(&arr_ty); let label = format!("{}() callback", cx.name); - if matches!(cmp_ty, PhpType::Object(_) | PhpType::Mixed) { - if let ExprKind::Closure { - params, - variadic, - variadic_by_ref, - return_type, - body, - captures, - capture_refs, - .. - } = &cx.args[1].kind - { - cx.checker.infer_closure_type_with_param_hints( - params, - variadic, - *variadic_by_ref, - return_type, - body, - captures, - capture_refs, - &cx.args[1], - cx.env, - &[cmp_ty.clone(), cmp_ty.clone()], - )?; - } else { - cx.checker.infer_type(&cx.args[1], cx.env)?; - let (cmp_arg, elem_binding) = - crate::types::checker::builtins::comparator_dummy_arg_for_elem(&cmp_ty, cx.span); - let dummy_args = vec![cmp_arg.clone(), cmp_arg]; - let mut env_with_elem; - let cb_env: &crate::types::TypeEnv = match &elem_binding { - Some((binding_name, binding_ty)) => { - env_with_elem = cx.env.clone(); - env_with_elem.insert(binding_name.clone(), binding_ty.clone()); - &env_with_elem - } - None => cx.env, - }; - crate::types::checker::builtins::check_callback_builtin_call( - cx.checker, - &cx.args[1], - &dummy_args, - cx.span, - cb_env, - &label, - )?; - } - } else { - cx.checker.infer_type(&cx.args[1], cx.env)?; - let cmp_arg = - crate::types::checker::builtins::dummy_arg_for_array_scalar_elem(&arr_ty, cx.span); - let dummy_args = vec![cmp_arg.clone(), cmp_arg]; - crate::types::checker::builtins::check_callback_builtin_call( - cx.checker, - &cx.args[1], - &dummy_args, - cx.span, - cx.env, - &label, - )?; - } + let callback_arg_types = [cmp_ty.clone(), cmp_ty]; + crate::types::checker::builtins::check_array_callback_builtin_call( + cx.checker, + &cx.args[1], + &callback_arg_types, + cx.span, + cx.env, + &label, + )?; Ok(PhpType::Void) } diff --git a/src/builtins/array/usort.rs b/src/builtins/array/usort.rs index afc2f7bb05..14ffff5e33 100644 --- a/src/builtins/array/usort.rs +++ b/src/builtins/array/usort.rs @@ -9,9 +9,8 @@ //! - The golden signature is `first_param_ref(fixed(["array", "callback"]))`: exactly 2 //! arguments, the `array` param is by-reference. The `ref` marker drives in-place //! mutation (ir_lower reads `ref_params` from the registry sig). -//! - `check` derives the comparator element type from the array value type, validates the -//! callback with two dummy element arguments (comparator receives two values), and handles -//! object-element arrays with typed closure hints. Returns `Void`. +//! - `check` derives the comparator element type from the array value type and validates both +//! callback parameters contextually, including object and opaque element types. Returns `Void`. //! - `lower` is a thin wrapper over the shared `arrays::lower_usort` emitter. use crate::builtins::spec::BuiltinCheckCtx; @@ -19,7 +18,6 @@ use crate::codegen::context::FunctionContext; use crate::codegen::CodegenIrError; use crate::errors::CompileError; use crate::ir::Instruction; -use crate::parser::ast::ExprKind; use crate::types::PhpType; builtin! { @@ -36,77 +34,23 @@ builtin! { /// Validates the array and comparator callback arguments for a `usort` call. /// -/// Infers the array value element type, and validates the comparator with two dummy -/// arguments of that element type. Object-element arrays use typed closure hints so -/// an unannotated comparator body (`$a <=> $b`) is checked against the real type; a -/// Mixed element (unknown-element array) takes the same path so a TYPED comparator's -/// declared parameter contract stands (runtime-enforced) instead of a fabricated Int. +/// Infers the array value element type and validates both comparator arguments with that type. +/// Closure bodies receive contextual hints for unannotated +/// parameters, while `Mixed`/`Never` elements leave explicit declarations authoritative. /// Arity (exactly 2) is pre-validated by the registry. Returns `Ok(PhpType::Void)`. fn check(cx: &mut BuiltinCheckCtx) -> Result { let arr_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; let cmp_ty = crate::types::checker::builtins::array_element_type(&arr_ty); let label = format!("{}() callback", cx.name); - if matches!(cmp_ty, PhpType::Object(_) | PhpType::Mixed) { - if let ExprKind::Closure { - params, - variadic, - variadic_by_ref, - return_type, - body, - captures, - capture_refs, - .. - } = &cx.args[1].kind - { - cx.checker.infer_closure_type_with_param_hints( - params, - variadic, - *variadic_by_ref, - return_type, - body, - captures, - capture_refs, - &cx.args[1], - cx.env, - &[cmp_ty.clone(), cmp_ty.clone()], - )?; - } else { - cx.checker.infer_type(&cx.args[1], cx.env)?; - let (cmp_arg, elem_binding) = - crate::types::checker::builtins::comparator_dummy_arg_for_elem(&cmp_ty, cx.span); - let dummy_args = vec![cmp_arg.clone(), cmp_arg]; - let mut env_with_elem; - let cb_env: &crate::types::TypeEnv = match &elem_binding { - Some((binding_name, binding_ty)) => { - env_with_elem = cx.env.clone(); - env_with_elem.insert(binding_name.clone(), binding_ty.clone()); - &env_with_elem - } - None => cx.env, - }; - crate::types::checker::builtins::check_callback_builtin_call( - cx.checker, - &cx.args[1], - &dummy_args, - cx.span, - cb_env, - &label, - )?; - } - } else { - cx.checker.infer_type(&cx.args[1], cx.env)?; - let cmp_arg = - crate::types::checker::builtins::dummy_arg_for_array_scalar_elem(&arr_ty, cx.span); - let dummy_args = vec![cmp_arg.clone(), cmp_arg]; - crate::types::checker::builtins::check_callback_builtin_call( - cx.checker, - &cx.args[1], - &dummy_args, - cx.span, - cx.env, - &label, - )?; - } + let callback_arg_types = [cmp_ty.clone(), cmp_ty]; + crate::types::checker::builtins::check_array_callback_builtin_call( + cx.checker, + &cx.args[1], + &callback_arg_types, + cx.span, + cx.env, + &label, + )?; Ok(PhpType::Void) } diff --git a/src/builtins/io/fseek.rs b/src/builtins/io/fseek.rs index ce0f44e529..ca045acf7c 100644 --- a/src/builtins/io/fseek.rs +++ b/src/builtins/io/fseek.rs @@ -7,9 +7,8 @@ //! //! Key details: //! - `check` calls `ensure_stream_resource` on the stream argument for validation and -//! returns `Union(Int, Bool)`. `returns: Mixed` is used because the union cannot be -//! expressed through the scalar `returns:` field. Arguments are pre-inferred by the -//! registry before the hook runs. +//! returns `Int`, matching PHP's `0` success / `-1` failure contract. Arguments are +//! pre-inferred by the registry before the hook runs. //! - `lower` is a thin wrapper over `io::lower_fseek` in the EIR backend. use crate::builtins::spec::{BuiltinCheckCtx, DefaultSpec}; @@ -23,14 +22,14 @@ builtin! { name: "fseek", area: Io, params: [stream: Mixed, offset: Int, whence: Int = DefaultSpec::Int(0)], - returns: Mixed, + returns: Int, check: check, lower: lower, summary: "Seeks on a file pointer.", php_manual: "function.fseek", } -/// Validates the stream argument and returns `Union(Int, Bool)` for the seek result. +/// Validates the stream argument and returns `Int` for the seek result. fn check(cx: &mut BuiltinCheckCtx) -> Result { crate::types::checker::builtins::io::common::ensure_stream_resource( cx.checker, @@ -38,7 +37,7 @@ fn check(cx: &mut BuiltinCheckCtx) -> Result { &cx.args[0], cx.env, )?; - Ok(cx.checker.normalize_union_type(vec![PhpType::Int, PhpType::False])) + Ok(PhpType::Int) } /// Lowers an `fseek` call by dispatching to the shared io emitter. diff --git a/src/types/checker/builtins/callables.rs b/src/types/checker/builtins/callables.rs index 6649cf8a9f..c1905ce90f 100644 --- a/src/types/checker/builtins/callables.rs +++ b/src/types/checker/builtins/callables.rs @@ -3,7 +3,7 @@ //! Holds the per-builtin `pub(crate)` check functions (`check_call_user_func`, //! `check_call_user_func_array`, `check_function_exists`, `check_preg_replace_callback_first_class_call`) //! relocated out of the old `check_builtin` dispatcher, plus the shared callback-validation helpers -//! (`check_callback_builtin_call`, `array_element_type`, dummy-argument builders, etc.) reused across +//! (`check_callback_builtin_call`, contextual array-argument builders, etc.) reused across //! the array and spl builtin checkers. //! //! Called from: @@ -114,31 +114,11 @@ fn specialize_dynamic_assoc_variadic_first_class_callback( Ok(()) } -/// Produces a dummy expression of the appropriate scalar type for an array's element. -/// -/// Selects `Str`, `Float`, `Bool`, or `Int` based on the element type of `arr_ty`. -/// Used to fabricate placeholder call arguments when type-checking array-callback builtins. -pub(crate) fn dummy_arg_for_array_scalar_elem(arr_ty: &PhpType, span: crate::span::Span) -> Expr { - let elem_ty = match arr_ty { - PhpType::Array(elem_ty) => elem_ty.as_ref(), - PhpType::AssocArray { value, .. } => value.as_ref(), - _ => &PhpType::Int, - }; - match elem_ty { - PhpType::Str => Expr::new(ExprKind::StringLiteral(String::new()), span), - PhpType::Float => Expr::new(ExprKind::FloatLiteral(0.0), span), - PhpType::Bool | PhpType::False => Expr::new(ExprKind::BoolLiteral(false), span), - _ => Expr::new(ExprKind::IntLiteral(0), span), - } -} - /// Returns the element type carried by an array/associative-array type. /// -/// A `Mixed` receiver (e.g. an `array`-hinted property whose element type only phpdoc knows) -/// yields `Mixed` elements, so a typed callback's declared parameter contract stands instead of -/// being checked against a fabricated placeholder. Falls back to `Int` for other non-array -/// types so callers can build a placeholder comparator argument without special-casing every -/// caller. +/// A `Mixed` receiver yields `Mixed` elements so callback validation can preserve the +/// declaration as the only available contract. Other non-array types retain the historical +/// `Int` fallback; callers that require arrays diagnose the invalid container separately. pub(crate) fn array_element_type(arr_ty: &PhpType) -> PhpType { match arr_ty { PhpType::Array(elem_ty) => (**elem_ty).clone(), @@ -148,47 +128,130 @@ pub(crate) fn array_element_type(arr_ty: &PhpType) -> PhpType { } } -/// Reserved synthetic variable name used to give a comparator's dummy argument an -/// object element type. It begins with a digit, so it can never collide with a -/// real PHP variable name (`[A-Za-z_]\w*`). -const COMPARATOR_ELEM_PLACEHOLDER: &str = "0__elephc_cmp_elem"; +/// Prefix for synthetic callback arguments; PHP identifiers cannot begin with a digit. +const CALLBACK_ARG_PLACEHOLDER_PREFIX: &str = "0__elephc_callback_arg"; + +/// Returns the contextual closure-parameter type for one callback argument. +/// +/// `Mixed` and `Never` mean the array element is statically opaque, so an unannotated +/// closure parameter remains `Mixed`; explicit declarations are preserved by closure inference. +fn contextual_callback_param_type(ty: &PhpType) -> PhpType { + if matches!(ty, PhpType::Mixed | PhpType::Never) { + PhpType::Mixed + } else { + ty.clone() + } +} -/// Builds a dummy comparator argument for one array element, plus an optional -/// `(name, type)` environment binding for element types that have no literal form. +/// Builds one checker-only callback argument and records non-literal types in `env`. /// -/// Scalar elements use a literal placeholder, exactly like -/// [`dummy_arg_for_array_scalar_elem`]. Object elements have no literal, so a -/// reserved synthetic variable (see [`COMPARATOR_ELEM_PLACEHOLDER`]) is bound to -/// the element type and returned as the binding; the caller must insert it into -/// the environment used for callback validation so a typed comparator parameter -/// (`function (DateTime $a, DateTime $b)`) is checked against the real type. -pub(crate) fn comparator_dummy_arg_for_elem( - elem_ty: &PhpType, +/// Opaque `Mixed`/`Never` elements use the bottom type so a declared callback contract is +/// not rejected against information the array type does not contain. Known compound types +/// retain their real type and therefore still receive normal compatibility validation. +fn callback_dummy_arg_for_type( + ty: &PhpType, + index: usize, span: crate::span::Span, -) -> (Expr, Option<(String, PhpType)>) { - match elem_ty { - PhpType::Str => (Expr::new(ExprKind::StringLiteral(String::new()), span), None), - PhpType::Float => (Expr::new(ExprKind::FloatLiteral(0.0), span), None), - PhpType::Bool | PhpType::False => { - (Expr::new(ExprKind::BoolLiteral(false), span), None) + env: &mut TypeEnv, +) -> Expr { + match ty { + PhpType::Int => Expr::new(ExprKind::IntLiteral(0), span), + PhpType::Float => Expr::new(ExprKind::FloatLiteral(0.0), span), + PhpType::Str => Expr::new(ExprKind::StringLiteral(String::new()), span), + PhpType::Bool | PhpType::False => Expr::new(ExprKind::BoolLiteral(false), span), + PhpType::Void => Expr::new(ExprKind::Null, span), + _ => { + let name = format!("{}_{}", CALLBACK_ARG_PLACEHOLDER_PREFIX, index); + let binding_ty = if matches!(ty, PhpType::Mixed | PhpType::Never) { + PhpType::Never + } else { + ty.clone() + }; + env.insert(name.clone(), binding_ty); + Expr::new(ExprKind::Variable(name), span) } - // Object and Mixed elements have no literal form: a reserved synthetic variable is - // bound to the element type so a typed callback parameter is checked against the real - // (or unknown-and-runtime-enforced) type instead of a fabricated Int placeholder. - PhpType::Object(_) | PhpType::Mixed => ( - Expr::new(ExprKind::Variable(COMPARATOR_ELEM_PLACEHOLDER.to_string()), span), - Some((COMPARATOR_ELEM_PLACEHOLDER.to_string(), elem_ty.clone())), - ), - // A Never element (empty/unknown array) binds Mixed: the callback is never invoked at - // runtime for an empty array, so its declared parameter contract must stand unchecked. - PhpType::Never => ( - Expr::new(ExprKind::Variable(COMPARATOR_ELEM_PLACEHOLDER.to_string()), span), - Some((COMPARATOR_ELEM_PLACEHOLDER.to_string(), PhpType::Mixed)), - ), - _ => (Expr::new(ExprKind::IntLiteral(0), span), None), } } +/// Validates an array-callback builtin using real element types or opaque argument slots. +/// +/// Closure literals are first checked with contextual parameter hints, then invoked against +/// checker-only arguments. Known element types remain strict; `Mixed`/`Never` elements defer +/// the element-to-declaration comparison without weakening global type compatibility. +pub(crate) fn check_array_callback_builtin_call( + checker: &mut Checker, + callback: &Expr, + callback_arg_types: &[PhpType], + span: crate::span::Span, + env: &TypeEnv, + label: &str, +) -> Result { + let mut callback_env = env.clone(); + let callback_args = callback_arg_types + .iter() + .enumerate() + .map(|(index, ty)| callback_dummy_arg_for_type(ty, index, span, &mut callback_env)) + .collect::>(); + + if let ExprKind::Closure { + params, + variadic, + variadic_by_ref, + return_type, + body, + captures, + capture_refs, + by_ref_return, + .. + } = &callback.kind + { + let param_hints = callback_arg_types + .iter() + .map(contextual_callback_param_type) + .collect::>(); + checker.infer_closure_type_with_param_hints( + params, + variadic, + *variadic_by_ref, + return_type, + body, + captures, + capture_refs, + callback, + env, + ¶m_hints, + )?; + let sig = checker.resolve_closure_signature_with_param_hints( + params, + variadic, + *variadic_by_ref, + return_type, + body, + captures, + *by_ref_return, + callback.span, + env, + ¶m_hints, + )?; + return checker.check_known_callable_call( + &sig, + &callback_args, + span, + &callback_env, + label, + ); + } + + check_callback_builtin_call( + checker, + callback, + &callback_args, + span, + &callback_env, + label, + ) +} + /// Checks object or array callable call and reports a compile error when it is invalid. fn check_object_or_array_callable_call( checker: &mut Checker, @@ -1222,22 +1285,19 @@ pub(crate) fn check_function_exists( Ok(PhpType::Bool) } -/// Builds synthetic callback arguments for `array_filter()` based on a static mode. +/// Returns contextual callback argument types for `array_filter()` based on a static mode. /// /// Unknown or invalid runtime modes use the default value-only shape for type checking; /// runtime validation still throws before invoking the callback when the mode is invalid. -pub(crate) fn array_filter_callback_dummy_args( +pub(crate) fn array_filter_callback_arg_types( arr_ty: &PhpType, mode_arg: Option<&Expr>, - span: crate::span::Span, -) -> Vec { +) -> Vec { + let elem_ty = array_element_type(arr_ty); match mode_arg.and_then(static_array_filter_mode_value) { - Some(1) => vec![ - dummy_arg_for_array_scalar_elem(arr_ty, span), - Expr::new(ExprKind::IntLiteral(0), span), - ], - Some(2) => vec![Expr::new(ExprKind::IntLiteral(0), span)], - _ => vec![dummy_arg_for_array_scalar_elem(arr_ty, span)], + Some(1) => vec![elem_ty, PhpType::Int], + Some(2) => vec![PhpType::Int], + _ => vec![elem_ty], } } diff --git a/src/types/checker/builtins/mod.rs b/src/types/checker/builtins/mod.rs index 58bdb2ec4e..30801e284e 100644 --- a/src/types/checker/builtins/mod.rs +++ b/src/types/checker/builtins/mod.rs @@ -27,11 +27,11 @@ pub(crate) use catalog::{ supported_builtin_function_names, }; pub(crate) use callables::{ - array_element_type, array_filter_callback_dummy_args, callback_supports_complex_descriptor_env, - check_call_user_func, check_call_user_func_array, + array_element_type, array_filter_callback_arg_types, callback_supports_complex_descriptor_env, + check_array_callback_builtin_call, check_call_user_func, check_call_user_func_array, check_callback_builtin_call, check_function_exists, check_preg_replace_callback_first_class_call, - comparator_dummy_arg_for_elem, dummy_arg_for_array_scalar_elem, runtime_callable_array_type, + runtime_callable_array_type, }; impl Checker { diff --git a/src/types/checker/callables/closures.rs b/src/types/checker/callables/closures.rs index df5405eca8..4b2db2d6c3 100644 --- a/src/types/checker/callables/closures.rs +++ b/src/types/checker/callables/closures.rs @@ -30,31 +30,6 @@ pub(crate) struct ClosureSignatureContext { } impl Checker { - /// Validates captured variables exist in the current environment, then builds a - /// `ClosureSignatureContext` by resolving parameter type annotations, default value - /// compatibility, and inserting parameter bindings into a cloned environment that - /// includes variadic and capture bindings. Returns the context for use in closure - /// return type inference. - pub(crate) fn prepare_closure_signature_context( - &mut self, - params: &[(String, Option, Option, bool)], - variadic: &Option, - variadic_by_ref: bool, - captures: &[String], - span: Span, - env: &TypeEnv, - ) -> Result { - self.prepare_closure_signature_context_with_param_hints( - params, - variadic, - variadic_by_ref, - captures, - span, - env, - &[], - ) - } - /// Builds the closure signature/environment, typing unannotated parameters /// from `contextual_param_types` when a hint is available at that position. /// @@ -139,6 +114,49 @@ impl Checker { }) } + /// Resolves a closure signature while applying contextual types only to unannotated params. + /// + /// Array-callback builtins use this after checking the closure body with the same hints so + /// invocation validation and return inference observe one consistent parameter environment. + pub(crate) fn resolve_closure_signature_with_param_hints( + &mut self, + params: &[(String, Option, Option, bool)], + variadic: &Option, + variadic_by_ref: bool, + return_type: &Option, + body: &[Stmt], + captures: &[String], + by_ref_return: bool, + span: Span, + env: &TypeEnv, + param_hints: &[PhpType], + ) -> Result { + let closure_sig = self.prepare_closure_signature_context_with_param_hints( + params, + variadic, + variadic_by_ref, + captures, + span, + env, + param_hints, + )?; + let (return_type, declared_return) = + self.resolve_closure_return_type(body, return_type, span, &closure_sig.env)?; + Ok(FunctionSig { + params: closure_sig.params, + param_type_exprs: closure_sig.param_type_exprs, + param_attributes: Vec::new(), + defaults: closure_sig.defaults, + return_type, + declared_return, + by_ref_return, + ref_params: closure_sig.ref_params, + declared_params: closure_sig.declared_params, + variadic: variadic.clone(), + deprecation: None, + }) + } + /// Infers the return type of a closure body by collecting all return statements, then /// comparing against an optional declared return type annotation. Validates coverage, /// compatibility, never-return constraints, and Generator yield semantics. Returns @@ -237,35 +255,20 @@ impl Checker { capture_refs: _, by_ref_return, .. - } => { - let closure_sig = self.prepare_closure_signature_context( + } => self + .resolve_closure_signature_with_param_hints( params, variadic, *variadic_by_ref, + return_type, + body, captures, + *by_ref_return, expr.span, env, - )?; - let (return_type, declared_return) = self.resolve_closure_return_type( - body, - return_type, - expr.span, - &closure_sig.env, - )?; - Ok(Some(FunctionSig { - params: closure_sig.params, - param_type_exprs: closure_sig.param_type_exprs, - param_attributes: Vec::new(), - defaults: closure_sig.defaults, - return_type, - declared_return, - by_ref_return: *by_ref_return, - ref_params: closure_sig.ref_params, - declared_params: closure_sig.declared_params, - variadic: variadic.clone(), - deprecation: None, - })) - } + &[], + ) + .map(Some), ExprKind::FirstClassCallable(target) => self .resolve_first_class_callable_sig(target, expr.span, env) .map(Some), diff --git a/src/types/checker/functions/call_validation.rs b/src/types/checker/functions/call_validation.rs index bc2bb123aa..ea6734c317 100644 --- a/src/types/checker/functions/call_validation.rs +++ b/src/types/checker/functions/call_validation.rs @@ -184,18 +184,12 @@ impl Checker { } match (expected, actual) { (PhpType::Mixed, _) => true, - // A `mixed` VALUE flowing into a narrower declared type is legal PHP — the engine - // enforces it at runtime (TypeError on mismatch). Trust the declaration, matching - // the runtime's boxed-Mixed representation funnels at the boundary. - (_, PhpType::Mixed) => true, (_, PhpType::Never) => true, // never is the bottom type — compatible with any expected type (PhpType::Bool, PhpType::False) => true, - // A union VALUE with at least one member the declaration accepts is likewise - // runtime-enforced PHP (e.g. an `int|false` seek result passed to an `int` param on - // the success path). A union with NO compatible member stays rejected. - (_, PhpType::Union(members)) if !matches!(expected, PhpType::Union(_)) => { - members.iter().any(|m| Self::types_compatible(expected, m)) - } + // The backend has explicit boxed-Mixed cast funnels for scalar boundaries. + // Refcounted/object boundaries do not yet validate the runtime tag, so keep + // those statically rejected instead of treating Mixed as universally safe. + (PhpType::Int | PhpType::Float | PhpType::Bool | PhpType::Str, PhpType::Mixed) => true, (PhpType::Iterable, PhpType::Array(_) | PhpType::AssocArray { .. } | PhpType::Iterable) => true, (PhpType::Union(expected_members), PhpType::Union(actual_members)) => actual_members .iter() diff --git a/tests/codegen/arrays/callbacks.rs b/tests/codegen/arrays/callbacks.rs index 2cbdd9adef..d18daca1e1 100644 --- a/tests/codegen/arrays/callbacks.rs +++ b/tests/codegen/arrays/callbacks.rs @@ -845,6 +845,62 @@ foreach ($uasorted as $value) { // --- array_map over heterogeneous (Mixed-element) input arrays --- +/// Verifies every value-callback checker keeps typed closure declarations over an +/// `array`-hinted parameter instead of fabricating integer element arguments. +#[test] +fn test_typed_array_callbacks_over_unknown_element_parameter() { + let out = compile_and_run( + r#" $value, $values); + echo count($mapped), ":"; + + $filtered = array_filter($values, static fn(false $value): bool => true); + echo count($filtered), ":"; + + $walked = $values; + array_walk($walked, static function (false $value): void { echo "w"; }); + echo ":"; + + $recursive = $values; + array_walk_recursive($recursive, static function (false $value): void { echo "r"; }); + echo ":"; + + $reduced = array_reduce( + $values, + static fn(int $carry, false $value): int => $carry + 1, + 0, + ); + echo $reduced, ":"; + + $found = array_find($values, static fn(false $value): bool => true); + echo gettype($found), ":"; + echo array_any($values, "is_false_value"), ":"; + echo array_all($values, static fn(false $value): bool => true), ":"; + + $difference = array_udiff( + $values, + [false], + static fn(false $left, false $right): int => 0, + ); + echo count($difference), ":"; + + $intersection = array_uintersect( + $values, + [false], + static fn(false $left, false $right): int => 0, + ); + echo count($intersection); +} + +exercise([false, false]); +"#, + ); + assert_eq!(out, "2:2:ww:rr:2:boolean:1:1:0:2"); +} + /// Verifies the reported repro: array_map with an untyped closure over a heterogeneous /// array preserves each element (the string element must not coerce to integer 0). #[test] diff --git a/tests/codegen/control_flow/functions.rs b/tests/codegen/control_flow/functions.rs index b756053569..49e21bade4 100644 --- a/tests/codegen/control_flow/functions.rs +++ b/tests/codegen/control_flow/functions.rs @@ -168,10 +168,7 @@ fn test_property_throw_guard_narrowing() { assert_eq!(out, "x"); } -/// A `mixed` VALUE flowing into a narrower declared boundary is legal PHP (the engine enforces -/// at runtime) — a mixed param passed on to a `string` param and a mixed value returned as a -/// declared `int` both compile and run byte-identically (the boxed-Mixed representation -/// funnels at the boundary). +/// Verifies boxed `mixed` values can cross scalar boundaries handled by the runtime cast funnels. #[test] fn test_mixed_value_into_typed_boundary() { let out = compile_and_run( @@ -180,25 +177,22 @@ fn test_mixed_value_into_typed_boundary() { assert_eq!(out, "HI:42"); } -/// A union VALUE with a member the declaration accepts crosses the boundary — an -/// `int|false`-typed seek result passed to an `int` param on its success path. -/// Byte-parity vs PHP 8.5. +/// Verifies `fseek()` has PHP's plain integer result type and can cross an `int` boundary. #[test] -fn test_union_value_into_narrower_param() { +fn test_fseek_int_result_into_typed_param() { let out = compile_and_run( "name; } function firstMode(string $mode, array $allowed): bool { return in_array($mode, $allowed); } function main(): void { $meta = ['mode' => 'r+', 'seekable' => true]; $mode = $meta['mode']; $ok = firstMode($mode, ['r+', 'w+']) ? 'ok' : 'no'; $items = [new Item('a'), new Item('b')]; $x = $items[1]; echo $ok, ':', label($x), ':', strtoupper($mode); } main();", + " 'r+', 'seekable' => true]; $mode = $meta['mode']; $ok = firstMode($mode, ['r+', 'w+']) ? 'ok' : 'no'; echo $ok, ':', strtoupper($mode); } main();", ); - assert_eq!(out, "ok:b:R+"); + assert_eq!(out, "ok:R+"); } /// A typed comparator over an `array`-hinted parameter keeps its declared parameter contract — diff --git a/tests/error_tests/array_builtins.rs b/tests/error_tests/array_builtins.rs index c662c91f26..95945e7fa7 100644 --- a/tests/error_tests/array_builtins.rs +++ b/tests/error_tests/array_builtins.rs @@ -478,6 +478,24 @@ fn test_indexed_array_unrelated_object_values_widen_to_mixed() { ); } +/// Verifies `array_map()` rejects object elements until its callback runtime supports them. +#[test] +fn test_error_array_map_rejects_object_elements() { + expect_error( + " $box, $items);", + "array_map() does not yet support object array elements", + ); +} + +/// Verifies contextual callback checking still rejects declarations incompatible with known elements. +#[test] +fn test_error_array_callback_rejects_known_element_mismatch() { + expect_error( + " $value, [1, 2]);", + "array_map() callback parameter $value expects Str, got Int", + ); +} + /// Verifies that error call user func array ref callback param requires variable. #[test] fn test_error_call_user_func_array_ref_callback_param_requires_variable() { diff --git a/tests/error_tests/type_system.rs b/tests/error_tests/type_system.rs index 14ccb234c6..a245e109a3 100644 --- a/tests/error_tests/type_system.rs +++ b/tests/error_tests/type_system.rs @@ -116,6 +116,24 @@ fn test_error_union_typed_local_rejects_invalid_initializer() { expect_error("