Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions src/builtins/array/array_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,32 @@ fn check(cx: &mut BuiltinCheckCtx) -> Result<PhpType, CompileError> {
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 {
Expand Down
6 changes: 4 additions & 2 deletions src/builtins/array/uasort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PhpType, CompileError> {
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,
Expand Down
6 changes: 4 additions & 2 deletions src/builtins/array/usort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PhpType, CompileError> {
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,
Expand Down
19 changes: 16 additions & 3 deletions src/types/checker/builtins/callables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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),
}
}
Expand Down
15 changes: 10 additions & 5 deletions src/types/checker/functions/call_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
44 changes: 44 additions & 0 deletions tests/codegen/control_flow/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<?php function takesStr(string $s): string { return strtoupper($s); } function relay(mixed $m): string { return takesStr($m); } function giveInt(mixed $m): int { return $m; } echo relay('hi'), ':', giveInt(42);",
);
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.
#[test]
fn test_union_value_into_narrower_param() {
let out = compile_and_run(
"<?php function requireZero(int $value, string $message): int { if ($value !== 0) { throw new \\RuntimeException($message); } return $value; } function main(): void { $f = fopen('php://temp', 'r+b'); fwrite($f, 'abcdef'); $r = requireZero(fseek($f, 2), 'seek failed'); echo $r, ':', fread($f, 3); } main();",
);
assert_eq!(out, "0:cde");
}

/// An assoc-array element (Mixed) into a `string` param plus an untyped-array element into an
/// object param. Byte-parity vs PHP 8.5.
#[test]
fn test_mixed_array_element_into_typed_params() {
let out = compile_and_run(
"<?php final class Item { public function __construct(public string $name) {} } function label(Item $i): string { return $i->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(
"<?php final class Box { public function __construct(public int $n) {} } function sorted(array $items): string { usort($items, static fn (Box $a, Box $b): int => $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");
}
Loading