Skip to content
Merged
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
51 changes: 51 additions & 0 deletions guide/src/types/closure.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,54 @@ pub fn callable_parameter(call: ZendCallable) {
}
# fn main() {}
```

### Named Arguments (PHP 8.0+)

You can call PHP functions with named arguments using `try_call_named` or
`try_call_with_named`. Named arguments allow you to pass parameters by name
rather than position, which is especially useful when dealing with functions
that have many optional parameters.

```rust,no_run
# #![cfg_attr(windows, feature(abi_vectorcall))]
# extern crate ext_php_rs;
use ext_php_rs::prelude::*;
use ext_php_rs::call_user_func_named;

#[php_function]
pub fn call_with_named_args() -> String {
// Get str_replace function
let str_replace = ZendCallable::try_from_name("str_replace")
.expect("str_replace not found");

// Call with named arguments in any order
let result = str_replace.try_call_named(&[
("subject", &"Hello world"),
("search", &"world"),
("replace", &"PHP"),
]).expect("Failed to call str_replace");

result.string().unwrap_or_default()
}

#[php_function]
pub fn call_with_mixed_args(callback: ZendCallable) {
// Mix positional and named arguments
let result = callback.try_call_with_named(
&[&"positional_arg"], // positional args first
&[("named", &"named_value")], // then named args
).expect("Failed to call function");
dbg!(result);
}
# fn main() {}
```

There's also a convenient `call_user_func_named!` macro:

```rust,ignore
// Named arguments only
call_user_func_named!(callable, arg1: value1, arg2: value2)?;

// Positional arguments followed by named arguments
call_user_func_named!(callable, [pos1, pos2], named1: val1, named2: val2)?;
```
62 changes: 62 additions & 0 deletions src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,68 @@ macro_rules! call_user_func {
};
}

/// Attempts to call a given PHP callable with named arguments.
///
/// This macro supports PHP 8.0+ named arguments, allowing you to pass
/// arguments by name rather than position.
///
/// # Syntax
///
/// ```ignore
/// // Named arguments only
/// call_user_func_named!(callable, name1: value1, name2: value2)
///
/// // Positional arguments followed by named arguments
/// call_user_func_named!(callable, [pos1, pos2], name1: value1, name2: value2)
/// ```
///
/// # Parameters
///
/// * `$fn` - The 'function' to call. Can be an [`Arg`] or a [`Zval`].
/// * `$name: $value` - Named parameters as `name: value` pairs.
/// * `[$($pos),*]` - Optional positional parameters in square brackets.
///
/// # Examples
///
/// ```ignore
/// use ext_php_rs::{call_user_func_named, types::ZendCallable};
///
/// let str_replace = ZendCallable::try_from_name("str_replace").unwrap();
///
/// // Using named arguments only
/// let result = call_user_func_named!(str_replace,
/// search: "world",
/// replace: "PHP",
/// subject: "Hello world"
/// ).unwrap();
///
/// // Mixing positional and named arguments
/// let result = call_user_func_named!(str_replace, ["world", "PHP"],
/// subject: "Hello world"
/// ).unwrap();
/// ```
///
/// [`Arg`]: crate::args::Arg
/// [`Zval`]: crate::types::Zval
Comment thread
kakserpom marked this conversation as resolved.
/// Note: Parameter names must be valid Rust identifiers.
/// For other names, use `try_call_named` directly.
#[macro_export]
macro_rules! call_user_func_named {
// Named arguments only
($fn: expr, $($name: ident : $value: expr),+ $(,)?) => {
$fn.try_call_named(&[$((stringify!($name), &$value as &dyn $crate::convert::IntoZvalDyn)),+])
};

// Positional arguments followed by named arguments
($fn: expr, [$($pos: expr),* $(,)?], $($name: ident : $value: expr),+ $(,)?) => {
$fn.try_call_with_named(
&[$(&$pos as &dyn $crate::convert::IntoZvalDyn),*],
&[$((stringify!($name), &$value as &dyn $crate::convert::IntoZvalDyn)),+]
)
};

}

/// Parses a given list of arguments using the [`ArgParser`] class.
///
/// # Examples
Expand Down
133 changes: 132 additions & 1 deletion src/types/callable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::{
zend::ExecutorGlobals,
};

use super::Zval;
use super::{ZendHashTable, Zval};

/// Acts as a wrapper around a callable [`Zval`]. Allows the owner to call the
/// [`Zval`] as if it was a PHP function through the [`try_call`] method.
Expand Down Expand Up @@ -148,6 +148,137 @@ impl<'a> ZendCallable<'a> {
Ok(retval)
}
}

/// Attempts to call the callable with both positional and named arguments.
///
/// This method supports PHP 8.0+ named arguments, allowing you to pass
/// arguments by name rather than position. Named arguments are passed
/// after positional arguments.
///
/// # Parameters
///
/// * `params` - A list of positional parameters to call the function with.
/// * `named_params` - A list of named parameters as (name, value) tuples.
///
/// # Returns
///
/// Returns the result wrapped in [`Ok`] upon success.
///
/// # Errors
///
/// * If calling the callable fails, or an exception is thrown, an [`Err`]
/// is returned.
/// * If the number of parameters exceeds `u32::MAX`.
/// * If a parameter name contains a NUL byte.
///
/// # Example
///
/// ```no_run
/// use ext_php_rs::types::ZendCallable;
///
/// // Call str_replace with named arguments
/// let str_replace = ZendCallable::try_from_name("str_replace").unwrap();
/// let result = str_replace.try_call_with_named(
/// &[], // no positional args
/// &[("search", &"world"), ("replace", &"PHP"), ("subject", &"Hello world")],
/// ).unwrap();
/// assert_eq!(result.string(), Some("Hello PHP".into()));
/// ```
// TODO: Measure this
#[allow(clippy::inline_always)]
#[inline(always)]
pub fn try_call_with_named(
&self,
params: &[&dyn IntoZvalDyn],
named_params: &[(&str, &dyn IntoZvalDyn)],
) -> Result<Zval> {
if !self.0.is_callable() {
return Err(Error::Callable);
}

let mut retval = Zval::new();
let len = params.len();
let params = params
.iter()
.map(|val| val.as_zval(false))
.collect::<Result<Vec<_>>>()?;
let packed = params.into_boxed_slice();

// Build the named parameters hash table
let named_ht = if named_params.is_empty() {
None
} else {
let mut ht = ZendHashTable::with_capacity(named_params.len().try_into()?);
for &(name, val) in named_params {
let zval = val.as_zval(false)?;
ht.insert(name, zval)?;
}
Some(ht)
};

let named_ptr = named_ht
.as_ref()
.map_or(ptr::null_mut(), |ht| ptr::from_ref(&**ht).cast_mut());

let result = unsafe {
#[allow(clippy::used_underscore_items)]
_call_user_function_impl(
ptr::null_mut(),
ptr::from_ref(self.0.as_ref()).cast_mut(),
&raw mut retval,
len.try_into()?,
packed.as_ptr().cast_mut(),
named_ptr,
)
};

if result < 0 {
Err(Error::Callable)
} else if let Some(e) = ExecutorGlobals::take_exception() {
Err(Error::Exception(e))
} else {
Ok(retval)
}
}

/// Attempts to call the callable with only named arguments.
///
/// This is a convenience method equivalent to calling
/// [`try_call_with_named`] with an empty positional arguments vector.
///
/// # Parameters
///
/// * `named_params` - A list of named parameters as (name, value) tuples.
///
/// # Returns
///
/// Returns the result wrapped in [`Ok`] upon success.
///
/// # Errors
///
/// * If calling the callable fails, or an exception is thrown, an [`Err`]
/// is returned.
/// * If a parameter name contains a NUL byte.
///
/// # Example
///
/// ```no_run
/// use ext_php_rs::types::ZendCallable;
///
/// // Call array_fill with named arguments only
/// let array_fill = ZendCallable::try_from_name("array_fill").unwrap();
/// let result = array_fill.try_call_named(&[
/// ("start_index", &0i64),
/// ("count", &3i64),
/// ("value", &"PHP"),
/// ]).unwrap();
/// ```
///
/// [`try_call_with_named`]: #method.try_call_with_named
#[inline]
pub fn try_call_named(&self, named_params: &[(&str, &dyn IntoZvalDyn)]) -> Result<Zval> {
self.try_call_with_named(&[], named_params)
}
}

impl<'a> FromZval<'a> for ZendCallable<'a> {
Expand Down
2 changes: 1 addition & 1 deletion src/types/class_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ impl<T: RegisteredClass> ZendClassObject<T> {
pub(crate) fn std_offset() -> usize {
unsafe {
let null = NonNull::<Self>::dangling();
let base = null.as_ref() as *const Self;
let base = ptr::from_ref::<Self>(null.as_ref());
let std = &raw const null.as_ref().std;

(std as usize) - (base as usize)
Expand Down
29 changes: 29 additions & 0 deletions tests/src/integration/callable/callable.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
<?php

// Basic callable test
assert(test_callable(fn (string $a) => $a, 'test') === 'test');

// Named arguments test - order should not matter, args matched by name
$namedResult = test_callable_named(fn (string $a, string $b) => "$a-$b");
assert($namedResult === 'first-second', "Named args failed: expected 'first-second', got '$namedResult'");

// Mixed positional + named arguments test
$mixedResult = test_callable_mixed(fn (string $pos, string $named) => "$pos|$named");
assert($mixedResult === 'positional|named_value', "Mixed args failed: expected 'positional|named_value', got '$mixedResult'");

// Macro test with named arguments only
$macroNamedResult = test_callable_macro_named(fn (string $x, string $y) => "$x $y");
assert($macroNamedResult === 'hello world', "Macro named args failed: expected 'hello world', got '$macroNamedResult'");

// Macro test with positional + named arguments
$macroMixedResult = test_callable_macro_mixed(fn (string $first, string $second) => "$first,$second");
assert($macroMixedResult === 'first,second_val', "Macro mixed args failed: expected 'first,second_val', got '$macroMixedResult'");

// Empty named params (should behave like try_call)
$emptyNamedResult = test_callable_empty_named(fn (string $a) => "got:$a");
assert($emptyNamedResult === 'got:hello', "Empty named args failed: expected 'got:hello', got '$emptyNamedResult'");

// Built-in PHP function with named args (str_replace with args in non-standard order)
$builtinResult = test_callable_builtin_named();
assert($builtinResult === 'Hello PHP', "Builtin named args failed: expected 'Hello PHP', got '$builtinResult'");

// Duplicate named params - last value wins
$dupResult = test_callable_duplicate_named(fn (string $a) => "val:$a");
assert($dupResult === 'val:overwritten', "Duplicate named args failed: expected 'val:overwritten', got '$dupResult'");
68 changes: 66 additions & 2 deletions tests/src/integration/callable/mod.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,76 @@
use ext_php_rs::{prelude::*, types::Zval};
use ext_php_rs::{call_user_func_named, prelude::*, types::Zval};

#[php_function]
pub fn test_callable(call: ZendCallable, a: String) -> Zval {
call.try_call(vec![&a]).expect("Failed to call function")
}

/// Test calling a callable with only named arguments
#[php_function]
pub fn test_callable_named(call: ZendCallable) -> Zval {
call.try_call_named(&[("b", &"second"), ("a", &"first")])
.expect("Failed to call function with named args")
}

/// Test calling a callable with positional + named arguments
#[php_function]
pub fn test_callable_mixed(call: ZendCallable) -> Zval {
call.try_call_with_named(&[&"positional"], &[("named", &"named_value")])
.expect("Failed to call function with mixed args")
}

/// Test the `call_user_func_named!` macro with named arguments only
#[php_function]
pub fn test_callable_macro_named(call: ZendCallable) -> Zval {
call_user_func_named!(call, x: "hello", y: "world").expect("Failed to call function via macro")
}

/// Test the `call_user_func_named!` macro with positional + named arguments
#[php_function]
pub fn test_callable_macro_mixed(call: ZendCallable) -> Zval {
call_user_func_named!(call, ["first"], second: "second_val")
.expect("Failed to call function via macro with mixed args")
}

/// Test calling with empty named params (should behave like `try_call`)
#[php_function]
pub fn test_callable_empty_named(call: ZendCallable) -> Zval {
call.try_call_with_named(&[&"hello"], &[])
.expect("Failed to call function with empty named args")
}

/// Test calling a built-in PHP function with named arguments
#[php_function]
pub fn test_callable_builtin_named() -> Zval {
let str_replace =
ZendCallable::try_from_name("str_replace").expect("Failed to get str_replace");
str_replace
.try_call_named(&[
("subject", &"Hello world"),
("replace", &"PHP"),
("search", &"world"),
])
.expect("Failed to call str_replace with named args")
}

/// Test calling with duplicate named params
#[php_function]
pub fn test_callable_duplicate_named(call: ZendCallable) -> Zval {
// When duplicates are passed, the last value wins (PHP hash table behavior)
call.try_call_named(&[("a", &"first"), ("a", &"overwritten")])
.expect("Failed to call function with duplicate named args")
}

pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder {
builder.function(wrap_function!(test_callable))
builder
.function(wrap_function!(test_callable))
.function(wrap_function!(test_callable_named))
.function(wrap_function!(test_callable_mixed))
.function(wrap_function!(test_callable_macro_named))
.function(wrap_function!(test_callable_macro_mixed))
.function(wrap_function!(test_callable_empty_named))
.function(wrap_function!(test_callable_builtin_named))
.function(wrap_function!(test_callable_duplicate_named))
}

#[cfg(test)]
Expand Down
Loading