diff --git a/Cargo.toml b/Cargo.toml index c372e0599..e0e5ceb9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,12 @@ ext-php-rs-derive = { version = "=0.11.5", path = "./crates/macros" } [dev-dependencies] skeptic = "0.13" +criterion = { version = "0.8", features = ["html_reports"] } + +[[bench]] +name = "function_call" +harness = false +required-features = ["embed"] [build-dependencies] anyhow = "1" @@ -79,6 +85,16 @@ path = "tests/module.rs" name = "sapi_tests" path = "tests/sapi.rs" +[[test]] +name = "raw_functions_tests" +path = "tests/raw_functions.rs" +required-features = ["embed"] + +[[test]] +name = "cached_callable_tests" +path = "tests/cached_callable.rs" +required-features = ["embed"] + # Patch clang-sys and bindgen for preserve_none calling convention support (libclang 19/20) # Required for PHP 8.5+ on macOS ARM64 which uses TAILCALL VM mode # - clang-sys: Adds libclang 19/20 bindings (https://github.com/KyleMayes/clang-sys/pull/195) @@ -86,3 +102,4 @@ path = "tests/sapi.rs" [patch.crates-io] clang-sys = { git = "https://github.com/extphprs/clang-sys.git", branch = "preserve-none-support" } bindgen = { git = "https://github.com/extphprs/rust-bindgen.git", branch = "preserve-none-support" } + diff --git a/allowed_bindings.rs b/allowed_bindings.rs index 5f73f60d9..898904b87 100644 --- a/allowed_bindings.rs +++ b/allowed_bindings.rs @@ -63,6 +63,11 @@ bind! { zend_array_destroy, zend_array_dup, zend_call_known_function, + zend_call_function, + zend_fcall_info, + zend_fcall_info_cache, + _zend_fcall_info_cache, + zend_is_callable_ex, zend_fetch_function_str, zend_hash_str_find_ptr_lc, zend_ce_argument_count_error, diff --git a/benches/function_call.rs b/benches/function_call.rs new file mode 100644 index 000000000..85dd401a0 --- /dev/null +++ b/benches/function_call.rs @@ -0,0 +1,329 @@ +//! Benchmarks for PHP function call overhead in ext-php-rs. +//! +//! This benchmark suite measures the performance overhead of calling PHP +//! functions from Rust using various approaches: +//! +//! - Standard `#[php_function]` with type conversion +//! - Raw function access (direct `zend_execute_data` access) +//! - Different argument types (primitives, strings, arrays) + +#![cfg_attr(windows, feature(abi_vectorcall))] +#![allow( + missing_docs, + deprecated, + clippy::uninlined_format_args, + clippy::cast_sign_loss, + clippy::cast_possible_wrap, + clippy::semicolon_if_nothing_returned, + clippy::explicit_iter_loop, + clippy::must_use_candidate, + clippy::needless_pass_by_value, + clippy::implicit_hasher, + clippy::doc_markdown +)] + +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; +use ext_php_rs::builders::SapiBuilder; +use ext_php_rs::embed::{Embed, ext_php_rs_sapi_startup}; +use ext_php_rs::ffi::{ + php_module_startup, php_request_shutdown, php_request_startup, sapi_startup, +}; +use ext_php_rs::prelude::*; +use ext_php_rs::zend::try_catch_first; +use std::collections::HashMap; +use std::panic::RefUnwindSafe; +use std::sync::Once; + +static INIT: Once = Once::new(); +static mut INITIALIZED: bool = false; + +/// Initialize PHP SAPI for benchmarks +fn ensure_php_initialized() { + INIT.call_once(|| { + let builder = SapiBuilder::new("bench", "Benchmark"); + let sapi = builder.build().unwrap().into_raw(); + let module = get_module(); + + unsafe { + ext_php_rs_sapi_startup(); + sapi_startup(sapi); + php_module_startup(sapi, module); + INITIALIZED = true; + } + }); +} + +/// Start a PHP request context for benchmarks +fn with_php_request R + RefUnwindSafe>(mut f: F) -> R { + ensure_php_initialized(); + + unsafe { + php_request_startup(); + } + + let result = try_catch_first(&mut f).unwrap_or_default(); + + unsafe { + php_request_shutdown(std::ptr::null_mut()); + } + + result +} + +// ============================================================================ +// Standard #[php_function] implementations +// ============================================================================ + +/// Simple function that returns a constant - baseline for function call +/// overhead +#[php_function] +pub fn bench_noop() -> i64 { + 42 +} + +/// Function taking a single i64 argument +#[php_function] +pub fn bench_single_int(n: i64) -> i64 { + n + 1 +} + +/// Function taking two i64 arguments +#[php_function] +pub fn bench_two_ints(a: i64, b: i64) -> i64 { + a + b +} + +/// Function taking a String argument +#[php_function] +pub fn bench_string(s: String) -> i64 { + s.len() as i64 +} + +/// Function taking a Vec argument +#[php_function] +pub fn bench_vec(v: Vec) -> i64 { + v.iter().sum() +} + +/// Function taking a HashMap argument +#[php_function] +pub fn bench_hashmap(m: HashMap) -> i64 { + m.values().sum() +} + +/// Function taking multiple mixed arguments +#[php_function] +pub fn bench_mixed(a: i64, s: String, b: i64) -> i64 { + a + b + s.len() as i64 +} + +// ============================================================================ +// Raw function implementations using #[php(raw)] - zero overhead +// ============================================================================ + +use ext_php_rs::types::Zval; +use ext_php_rs::zend::ExecuteData; + +/// Raw function - direct access to ExecuteData and Zval +/// This bypasses all argument parsing and type conversion +#[php_function] +#[php(raw)] +pub fn bench_raw_noop(_ex: &mut ExecuteData, retval: &mut Zval) { + retval.set_long(42); +} + +/// Raw function taking a single int - manual argument extraction +#[php_function] +#[php(raw)] +pub fn bench_raw_single_int(ex: &mut ExecuteData, retval: &mut Zval) { + // Get the first argument using ExecuteData's new get_arg method + let n = unsafe { ex.get_arg(0) } + .and_then(|zv| zv.long()) + .unwrap_or(0); + retval.set_long(n + 1); +} + +/// Raw function that avoids all allocation - demonstrates zero-copy access +#[php_function] +#[php(raw)] +pub fn bench_raw_two_ints(ex: &mut ExecuteData, retval: &mut Zval) { + unsafe { + let a = ex.get_arg(0).and_then(|zv| zv.long()).unwrap_or(0); + let b = ex.get_arg(1).and_then(|zv| zv.long()).unwrap_or(0); + retval.set_long(a + b); + } +} + +// ============================================================================ +// Module registration +// ============================================================================ + +#[php_module] +pub fn build_module(module: ModuleBuilder) -> ModuleBuilder { + module + // Standard functions with type conversion + .function(wrap_function!(bench_noop)) + .function(wrap_function!(bench_single_int)) + .function(wrap_function!(bench_two_ints)) + .function(wrap_function!(bench_string)) + .function(wrap_function!(bench_vec)) + .function(wrap_function!(bench_hashmap)) + .function(wrap_function!(bench_mixed)) + // Raw functions - zero overhead + .function(wrap_function!(bench_raw_noop)) + .function(wrap_function!(bench_raw_single_int)) + .function(wrap_function!(bench_raw_two_ints)) +} + +// ============================================================================ +// Benchmarks +// ============================================================================ + +fn bench_function_call_overhead(c: &mut Criterion) { + let mut group = c.benchmark_group("function_call_overhead"); + + // ---- Standard functions (with type conversion) ---- + + // Benchmark: noop function (baseline) + group.bench_function("noop_standard", |b| { + b.iter(|| { + with_php_request(|| { + let result = Embed::eval("bench_noop();").unwrap(); + black_box(result.long().unwrap()) + }) + }) + }); + + // Benchmark: single int argument + group.bench_function("single_int_standard", |b| { + b.iter(|| { + with_php_request(|| { + let result = Embed::eval("bench_single_int(42);").unwrap(); + black_box(result.long().unwrap()) + }) + }) + }); + + // Benchmark: two int arguments + group.bench_function("two_ints_standard", |b| { + b.iter(|| { + with_php_request(|| { + let result = Embed::eval("bench_two_ints(21, 21);").unwrap(); + black_box(result.long().unwrap()) + }) + }) + }); + + // ---- Raw functions (zero overhead) ---- + + // Benchmark: raw noop function + group.bench_function("noop_raw", |b| { + b.iter(|| { + with_php_request(|| { + let result = Embed::eval("bench_raw_noop();").unwrap(); + black_box(result.long().unwrap()) + }) + }) + }); + + // Benchmark: raw single int argument + group.bench_function("single_int_raw", |b| { + b.iter(|| { + with_php_request(|| { + let result = Embed::eval("bench_raw_single_int(42);").unwrap(); + black_box(result.long().unwrap()) + }) + }) + }); + + // Benchmark: raw two int arguments + group.bench_function("two_ints_raw", |b| { + b.iter(|| { + with_php_request(|| { + let result = Embed::eval("bench_raw_two_ints(21, 21);").unwrap(); + black_box(result.long().unwrap()) + }) + }) + }); + + group.finish(); +} + +fn bench_type_conversion_overhead(c: &mut Criterion) { + let mut group = c.benchmark_group("type_conversion"); + + // String conversion + group.bench_function("string_short", |b| { + b.iter(|| { + with_php_request(|| { + let result = Embed::eval("bench_string('hello');").unwrap(); + black_box(result.long().unwrap()) + }) + }) + }); + + group.bench_function("string_long", |b| { + b.iter(|| { + with_php_request(|| { + let result = Embed::eval("bench_string(str_repeat('x', 1000));").unwrap(); + black_box(result.long().unwrap()) + }) + }) + }); + + // Vec conversion with different sizes + for size in [1, 10, 100, 1000].iter() { + group.throughput(Throughput::Elements(*size as u64)); + group.bench_with_input(BenchmarkId::new("vec", size), size, |b, &size| { + b.iter(|| { + with_php_request(|| { + let code = format!("bench_vec(range(1, {}));", size); + let result = Embed::eval(&code).unwrap(); + black_box(result.long().unwrap()) + }) + }) + }); + } + + // HashMap conversion with different sizes + for size in [1, 10, 100].iter() { + group.throughput(Throughput::Elements(*size as u64)); + group.bench_with_input(BenchmarkId::new("hashmap", size), size, |b, &size| { + b.iter(|| { + with_php_request(|| { + let code = format!( + "$arr = []; for ($i = 0; $i < {}; $i++) {{ $arr['key'.$i] = $i; }} bench_hashmap($arr);", + size + ); + let result = Embed::eval(&code).unwrap(); + black_box(result.long().unwrap_or(0)) + }) + }) + }); + } + + group.finish(); +} + +fn bench_mixed_arguments(c: &mut Criterion) { + let mut group = c.benchmark_group("mixed_arguments"); + + group.bench_function("mixed_3args", |b| { + b.iter(|| { + with_php_request(|| { + let result = Embed::eval("bench_mixed(10, 'hello', 20);").unwrap(); + black_box(result.long().unwrap()) + }) + }) + }); + + group.finish(); +} + +criterion_group!( + benches, + bench_function_call_overhead, + bench_type_conversion_overhead, + bench_mixed_arguments, +); +criterion_main!(benches); diff --git a/crates/macros/src/function.rs b/crates/macros/src/function.rs index bcc4865cc..82de6e3ba 100644 --- a/crates/macros/src/function.rs +++ b/crates/macros/src/function.rs @@ -31,12 +31,22 @@ struct PhpFunctionAttribute { optional: Option, vis: Option, attrs: Vec, + /// When true, generates a raw handler that directly receives `ExecuteData` + /// and `Zval` without any argument parsing or type conversion. This + /// provides zero-overhead function calls at the cost of manual argument + /// handling. + raw: bool, } pub fn parser(mut input: ItemFn) -> Result { let php_attr = PhpFunctionAttribute::from_attributes(&input.attrs)?; input.attrs.retain(|attr| !attr.path().is_ident("php")); + // Handle raw functions - zero overhead, direct access to ExecuteData and Zval + if php_attr.raw { + return parser_raw(&input, &php_attr); + } + let args = Args::parse_from_fnargs(input.sig.inputs.iter(), php_attr.defaults)?; if let Some(ReceiverArg { span, .. }) = args.receiver { bail!(span => "Receiver arguments are invalid on PHP functions. See `#[php_impl]`."); @@ -61,6 +71,66 @@ pub fn parser(mut input: ItemFn) -> Result { }) } +/// Parser for raw PHP functions that bypass argument parsing and type +/// conversion. +/// +/// Raw functions must have the signature: +/// ```ignore +/// fn(ex: &mut ExecuteData, retval: &mut Zval) +/// ``` +/// +/// This provides zero-overhead function calls for performance-critical code. +fn parser_raw(input: &ItemFn, php_attr: &PhpFunctionAttribute) -> Result { + let ident = &input.sig.ident; + let name = php_attr.rename.rename(ident.to_string(), RenameRule::Snake); + let docs = get_docs(&php_attr.attrs)?; + let internal_ident = format_ident!("_internal_{}", ident); + + // Validate function signature - should take (ex: &mut ExecuteData, retval: &mut + // Zval) + let args: Vec<_> = input.sig.inputs.iter().collect(); + if args.len() != 2 { + bail!(input.sig => "Raw PHP functions must take exactly two arguments: (ex: &mut ExecuteData, retval: &mut Zval)"); + } + + let docs_tokens = if docs.is_empty() { + quote! {} + } else { + quote! { + .docs(&[#(#docs),*]) + } + }; + + Ok(quote! { + #input + + #[doc(hidden)] + #[allow(non_camel_case_types)] + struct #internal_ident; + + impl ::ext_php_rs::internal::function::PhpFunction for #internal_ident { + const FUNCTION_ENTRY: fn() -> ::ext_php_rs::builders::FunctionBuilder<'static> = { + fn entry() -> ::ext_php_rs::builders::FunctionBuilder<'static> + { + ::ext_php_rs::builders::FunctionBuilder::new(#name, { + ::ext_php_rs::zend_fastcall! { + extern fn handler( + ex: &mut ::ext_php_rs::zend::ExecuteData, + retval: &mut ::ext_php_rs::types::Zval, + ) { + #ident(ex, retval) + } + } + handler + }) + #docs_tokens + } + entry + }; + } + }) +} + #[derive(Debug)] pub struct Function<'a> { /// Identifier of the Rust function associated with the function. diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index fa6f8c7fe..a9c15cd0b 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -586,6 +586,75 @@ fn php_interface_internal(_args: TokenStream2, input: TokenStream2) -> TokenStre /// You can also return a `Result` from the function. The error variant will be /// translated into an exception and thrown. See the section on /// [exceptions](../exceptions.md) for more details. +/// +/// ## Raw Functions +/// +/// For performance-critical code, you can use the `#[php(raw)]` attribute to +/// bypass all argument parsing and type conversion overhead. Raw functions +/// receive direct access to the `ExecuteData` and return `Zval`, giving you +/// complete control over argument handling. +/// +/// This is useful when: +/// - You need maximum performance and want to avoid allocation overhead +/// - You want to handle variadic arguments manually +/// - You need direct access to the PHP execution context +/// +/// ```rust,no_run,ignore +/// # #![cfg_attr(windows, feature(abi_vectorcall))] +/// # extern crate ext_php_rs; +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::types::Zval; +/// use ext_php_rs::zend::ExecuteData; +/// +/// #[php_function] +/// #[php(raw)] +/// pub fn fast_add(ex: &mut ExecuteData, retval: &mut Zval) { +/// // Get arguments directly without type conversion overhead +/// let a = unsafe { ex.get_arg(0) } +/// .and_then(|zv| zv.long()) +/// .unwrap_or(0); +/// let b = unsafe { ex.get_arg(1) } +/// .and_then(|zv| zv.long()) +/// .unwrap_or(0); +/// +/// retval.set_long(a + b); +/// } +/// +/// /// Sum all arguments passed to the function +/// #[php_function] +/// #[php(raw)] +/// pub fn sum_all(ex: &mut ExecuteData, retval: &mut Zval) { +/// let num_args = ex.num_args(); +/// let mut sum: i64 = 0; +/// +/// for i in 0..num_args { +/// if let Some(zv) = unsafe { ex.get_arg(i as usize) } { +/// sum += zv.long().unwrap_or(0); +/// } +/// } +/// +/// retval.set_long(sum); +/// } +/// +/// #[php_module] +/// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { +/// module +/// .function(wrap_function!(fast_add)) +/// .function(wrap_function!(sum_all)) +/// } +/// # fn main() {} +/// ``` +/// +/// ### `ExecuteData` Methods +/// +/// When using raw functions, you have access to these `ExecuteData` methods: +/// +/// - `num_args()` - Returns the number of arguments passed to the function +/// - `get_arg(n)` - Returns a reference to the argument at index `n` (0-based). +/// This is an unsafe method; the caller must ensure `n < num_args()` +/// +/// Raw functions bypass the standard argument parser, so you are responsible +/// for validating argument count and types yourself. // END DOCS FROM function.md #[proc_macro_attribute] pub fn php_function(args: TokenStream, input: TokenStream) -> TokenStream { diff --git a/docsrs_bindings.rs b/docsrs_bindings.rs index 450211170..bc4bb89a1 100644 --- a/docsrs_bindings.rs +++ b/docsrs_bindings.rs @@ -1217,6 +1217,7 @@ pub struct _zend_lazy_objects_store { } pub type zend_lazy_objects_store = _zend_lazy_objects_store; pub type zend_property_info = _zend_property_info; +pub type zend_fcall_info = _zend_fcall_info; pub type zend_fcall_info_cache = _zend_fcall_info_cache; pub type zend_object_read_property_t = ::std::option::Option< unsafe extern "C" fn( @@ -2059,6 +2060,16 @@ pub struct _zend_function_entry { } pub type zend_function_entry = _zend_function_entry; #[repr(C)] +pub struct _zend_fcall_info { + pub size: usize, + pub function_name: zval, + pub retval: *mut zval, + pub params: *mut zval, + pub object: *mut zend_object, + pub param_count: u32, + pub named_params: *mut HashTable, +} +#[repr(C)] #[derive(Debug, Copy, Clone)] pub struct _zend_fcall_info_cache { pub function_handler: *mut zend_function, @@ -2084,6 +2095,16 @@ unsafe extern "C" { orig_class_entry: *mut zend_class_entry, ) -> *mut zend_class_entry; } +unsafe extern "C" { + pub fn zend_is_callable_ex( + callable: *mut zval, + object: *mut zend_object, + check_flags: u32, + callable_name: *mut *mut zend_string, + fcc: *mut zend_fcall_info_cache, + error: *mut *mut ::std::os::raw::c_char, + ) -> bool; +} unsafe extern "C" { pub fn zend_is_callable( callable: *mut zval, @@ -2121,6 +2142,12 @@ unsafe extern "C" { named_params: *mut HashTable, ) -> zend_result; } +unsafe extern "C" { + pub fn zend_call_function( + fci: *mut zend_fcall_info, + fci_cache: *mut zend_fcall_info_cache, + ) -> zend_result; +} unsafe extern "C" { pub fn zend_call_known_function( fn_: *mut zend_function, diff --git a/guide/src/macros/function.md b/guide/src/macros/function.md index fd636b00d..812b198f8 100644 --- a/guide/src/macros/function.md +++ b/guide/src/macros/function.md @@ -152,3 +152,71 @@ pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { You can also return a `Result` from the function. The error variant will be translated into an exception and thrown. See the section on [exceptions](../exceptions.md) for more details. + +## Raw Functions + +For performance-critical code, you can use the `#[php(raw)]` attribute to bypass +all argument parsing and type conversion overhead. Raw functions receive direct +access to the `ExecuteData` and return `Zval`, giving you complete control over +argument handling. + +This is useful when: +- You need maximum performance and want to avoid allocation overhead +- You want to handle variadic arguments manually +- You need direct access to the PHP execution context + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; +use ext_php_rs::zend::ExecuteData; + +#[php_function] +#[php(raw)] +pub fn fast_add(ex: &mut ExecuteData, retval: &mut Zval) { + // Get arguments directly without type conversion overhead + let a = unsafe { ex.get_arg(0) } + .and_then(|zv| zv.long()) + .unwrap_or(0); + let b = unsafe { ex.get_arg(1) } + .and_then(|zv| zv.long()) + .unwrap_or(0); + + retval.set_long(a + b); +} + +/// Sum all arguments passed to the function +#[php_function] +#[php(raw)] +pub fn sum_all(ex: &mut ExecuteData, retval: &mut Zval) { + let num_args = ex.num_args(); + let mut sum: i64 = 0; + + for i in 0..num_args { + if let Some(zv) = unsafe { ex.get_arg(i as usize) } { + sum += zv.long().unwrap_or(0); + } + } + + retval.set_long(sum); +} + +#[php_module] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module + .function(wrap_function!(fast_add)) + .function(wrap_function!(sum_all)) +} +# fn main() {} +``` + +### `ExecuteData` Methods + +When using raw functions, you have access to these `ExecuteData` methods: + +- `num_args()` - Returns the number of arguments passed to the function +- `get_arg(n)` - Returns a reference to the argument at index `n` (0-based). This is an unsafe method; the caller must ensure `n < num_args()` + +Raw functions bypass the standard argument parser, so you are responsible for +validating argument count and types yourself. diff --git a/guide/src/types/functions.md b/guide/src/types/functions.md index 8a24a2be9..e7114980f 100644 --- a/guide/src/types/functions.md +++ b/guide/src/types/functions.md @@ -1,15 +1,18 @@ # Functions & methods -PHP functions and methods are represented by the `Function` struct. +PHP functions and methods can be called from Rust using several approaches, +depending on your performance needs. -You can use the `try_from_function` and `try_from_method` methods to obtain a Function struct corresponding to the passed function or static method name. -It's heavily recommended you reuse returned `Function` objects, to avoid the overhead of looking up the function/method name. +## Function Struct + +The `Function` struct represents a PHP function or method. You can use +`try_from_function` and `try_from_method` to obtain a `Function` struct +corresponding to the passed function or static method name. ```rust,no_run # #![cfg_attr(windows, feature(abi_vectorcall))] # extern crate ext_php_rs; use ext_php_rs::prelude::*; - use ext_php_rs::zend::Function; #[php_function] @@ -26,3 +29,124 @@ pub fn test_method() -> () { # fn main() {} ``` + +## ZendCallable + +`ZendCallable` wraps a PHP callable value (function name, closure, or +`[$object, 'method']` array) and allows you to call it from Rust. + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; +use ext_php_rs::types::ZendCallable; + +#[php_function] +pub fn call_callback(callback: ZendCallable) -> String { + let result = callback.try_call(vec![&"hello"]).unwrap(); + result.string().unwrap().to_string() +} + +# fn main() {} +``` + +## CachedCallable + +For performance-critical code that calls the same PHP function repeatedly, +`CachedCallable` provides optimized function calls by caching the function +lookup. This avoids the overhead of resolving the function name on every call. + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; +use ext_php_rs::types::CachedCallable; + +#[php_function] +pub fn process_items(items: Vec) -> Vec { + // Cache the function lookup once + let mut strtoupper = CachedCallable::try_from_name("strtoupper").unwrap(); + + items + .iter() + .map(|item| { + // Repeated calls use the cached function pointer + strtoupper + .try_call(vec![item]) + .unwrap() + .string() + .unwrap() + .to_string() + }) + .collect() +} + +# fn main() {} +``` + +### Creating a CachedCallable + +There are several ways to create a `CachedCallable`: + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::types::{CachedCallable, Zval}; + +# fn example() -> ext_php_rs::error::Result<()> { +// From a function name +let mut callable = CachedCallable::try_from_name("strtoupper")?; + +// From a &str (same as try_from_name) +let mut callable = CachedCallable::try_from("array_map")?; + +// From a Zval containing a callable +let zval = Zval::new(); +// ... set zval to a callable value ... +// let mut callable = CachedCallable::try_from_zval(zval)?; +# Ok(()) +# } +# fn main() {} +``` + +### Calling Methods + +You can also call the function with pre-converted `Zval` arguments using +`call_with_zvals` for even more control: + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; +use ext_php_rs::types::{CachedCallable, Zval}; +use ext_php_rs::convert::IntoZval; + +#[php_function] +pub fn example() -> i64 { + let mut callable = CachedCallable::try_from_name("max").unwrap(); + + // Using try_call with auto-conversion + let result = callable.try_call(vec![&1i64, &5i64, &3i64]).unwrap(); + + // Or using call_with_zvals with pre-converted values + let args: Vec = vec![ + 1i64.into_zval(false).unwrap(), + 5i64.into_zval(false).unwrap(), + 3i64.into_zval(false).unwrap(), + ]; + let result = callable.call_with_zvals(&args).unwrap(); + + result.long().unwrap() +} + +# fn main() {} +``` + +## Performance Considerations + +- **`Function`**: Performs a function lookup on each `try_from_*` call. Good for + one-off calls. +- **`ZendCallable`**: Validates the callable on creation. Use when receiving + callbacks from PHP. +- **`CachedCallable`**: Caches the function lookup. Best for repeated calls to + the same function in a loop or hot path. diff --git a/src/args.rs b/src/args.rs index c110b3b79..73ae4209e 100644 --- a/src/args.rs +++ b/src/args.rs @@ -208,17 +208,155 @@ impl From> for Parameter { /// Internal argument information used by Zend. pub type ArgInfo = zend_internal_arg_info; +/// Maximum number of arguments that can be stored on the stack. +/// Arguments beyond this count will be stored on the heap. +pub const MAX_STACK_ARGS: usize = 8; + +/// Storage for zval arguments that avoids heap allocation for common cases. +/// +/// For functions with <= 8 arguments, the zvals are stored directly on the +/// stack. For functions with more arguments, they are stored in a Vec on the +/// heap. +#[derive(Debug)] +pub enum ArgZvals<'a> { + /// Stack-allocated storage for up to `MAX_STACK_ARGS` arguments. + Stack { + /// The array of arguments stored on the stack. + args: [Option<&'a mut Zval>; MAX_STACK_ARGS], + /// The actual number of arguments (may be less than array size). + len: usize, + }, + /// Heap-allocated storage for more than `MAX_STACK_ARGS` arguments. + Heap(Vec>), +} + +impl<'a> ArgZvals<'a> { + /// Creates a new stack-based storage from an iterator of zvals. + /// Falls back to heap allocation if there are more than `MAX_STACK_ARGS` + /// arguments. + #[inline] + #[allow(clippy::should_implement_trait)] + pub fn from_iter>>(mut iter: I) -> Self { + let mut args: [Option<&'a mut Zval>; MAX_STACK_ARGS] = Default::default(); + let mut len = 0; + + // Fill the stack array + for i in 0..MAX_STACK_ARGS { + match iter.next() { + Some(arg) => { + args[i] = arg; + len = i + 1; + } + None => { + // All arguments fit on the stack + return Self::Stack { args, len }; + } + } + } + + // Check if there are more arguments that don't fit on the stack + if let Some(extra_arg) = iter.next() { + // Exceeded stack capacity, convert to heap + let mut vec: Vec> = args.into_iter().collect(); + vec.push(extra_arg); + vec.extend(iter); + return Self::Heap(vec); + } + + Self::Stack { args, len } + } + + /// Returns the number of arguments. + #[inline] + #[must_use] + pub fn len(&self) -> usize { + match self { + Self::Stack { len, .. } => *len, + Self::Heap(vec) => vec.len(), + } + } + + /// Returns true if there are no arguments. + #[inline] + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Converts self into an iterator over the arguments. + #[inline] + #[allow(clippy::should_implement_trait)] + pub fn into_iter(self) -> impl Iterator> { + ArgZvalsIter { + inner: self, + pos: 0, + } + } +} + +/// Iterator over `ArgZvals`. +struct ArgZvalsIter<'a> { + inner: ArgZvals<'a>, + pos: usize, +} + +impl<'a> Iterator for ArgZvalsIter<'a> { + type Item = Option<&'a mut Zval>; + + fn next(&mut self) -> Option { + let len = self.inner.len(); + if self.pos >= len { + return None; + } + let pos = self.pos; + self.pos += 1; + + match &mut self.inner { + ArgZvals::Stack { args, .. } => { + // Take the value out and replace with None + Some(args[pos].take()) + } + ArgZvals::Heap(vec) => Some(vec[pos].take()), + } + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.inner.len().saturating_sub(self.pos); + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for ArgZvalsIter<'_> {} + /// Parses the arguments of a function. #[must_use] pub struct ArgParser<'a, 'b> { args: Vec<&'b mut Arg<'a>>, min_num_args: Option, - arg_zvals: Vec>, + arg_zvals: ArgZvals<'a>, } impl<'a, 'b> ArgParser<'a, 'b> { /// Builds a new function argument parser. + /// + /// Note: For better performance, prefer using [`from_arg_zvals`] which + /// avoids heap allocation for functions with <= 8 arguments. + /// + /// [`from_arg_zvals`]: #method.from_arg_zvals pub fn new(arg_zvals: Vec>) -> Self { + ArgParser { + args: vec![], + min_num_args: None, + arg_zvals: ArgZvals::Heap(arg_zvals), + } + } + + /// Builds a new function argument parser with optimized storage. + /// + /// This constructor uses stack-based storage for functions with <= 8 + /// arguments, avoiding heap allocation for the common case. + #[inline] + pub fn from_arg_zvals(arg_zvals: ArgZvals<'a>) -> Self { ArgParser { args: vec![], min_num_args: None, @@ -282,6 +420,7 @@ impl<'a, 'b> ArgParser<'a, 'b> { return Err(Error::IncorrectArguments(num_args, min_num_args)); } + // Use the optimized iterator that works with both stack and heap storage for (i, arg_zval) in self.arg_zvals.into_iter().enumerate() { let arg = match self.args.get_mut(i) { Some(arg) => Some(arg), @@ -568,4 +707,75 @@ mod tests { } // TODO: test parse + + // ========================================================================= + // ArgZvals tests - Stack-based argument storage + // ========================================================================= + + #[test] + fn test_arg_zvals_empty() { + let zvals: ArgZvals = ArgZvals::from_iter(std::iter::empty()); + assert!(zvals.is_empty()); + assert_eq!(zvals.len(), 0); + } + + #[test] + fn test_arg_zvals_stack_single() { + let zvals = ArgZvals::from_iter([None::<&mut Zval>].into_iter()); + assert!(!zvals.is_empty()); + assert_eq!(zvals.len(), 1); + matches!(zvals, ArgZvals::Stack { len: 1, .. }); + } + + #[test] + fn test_arg_zvals_stack_max() { + // Test with exactly MAX_STACK_ARGS elements + let items: Vec> = (0..MAX_STACK_ARGS).map(|_| None).collect(); + let zvals = ArgZvals::from_iter(items.into_iter()); + assert_eq!(zvals.len(), MAX_STACK_ARGS); + matches!(zvals, ArgZvals::Stack { .. }); + } + + #[test] + fn test_arg_zvals_heap_overflow() { + // Test with more than MAX_STACK_ARGS elements - should use heap + let items: Vec> = (0..=MAX_STACK_ARGS).map(|_| None).collect(); + let zvals = ArgZvals::from_iter(items.into_iter()); + assert_eq!(zvals.len(), MAX_STACK_ARGS + 1); + matches!(zvals, ArgZvals::Heap(_)); + } + + #[test] + fn test_arg_zvals_heap_large() { + // Test with many more elements + let items: Vec> = (0..20).map(|_| None).collect(); + let zvals = ArgZvals::from_iter(items.into_iter()); + assert_eq!(zvals.len(), 20); + matches!(zvals, ArgZvals::Heap(_)); + } + + #[test] + fn test_arg_zvals_into_iter_stack() { + let items: Vec> = (0..3).map(|_| None).collect(); + let zvals = ArgZvals::from_iter(items.into_iter()); + let collected: Vec<_> = zvals.into_iter().collect(); + assert_eq!(collected.len(), 3); + } + + #[test] + fn test_arg_zvals_into_iter_heap() { + let items: Vec> = (0..12).map(|_| None).collect(); + let zvals = ArgZvals::from_iter(items.into_iter()); + let collected: Vec<_> = zvals.into_iter().collect(); + assert_eq!(collected.len(), 12); + } + + #[test] + fn test_arg_parser_from_arg_zvals() { + let items: Vec> = (0..3).map(|_| None).collect(); + let zvals = ArgZvals::from_iter(items.into_iter()); + let parser = ArgParser::from_arg_zvals(zvals); + assert_eq!(parser.arg_zvals.len(), 3); + assert!(parser.args.is_empty()); + } } diff --git a/src/builders/ini.rs b/src/builders/ini.rs index ca71a53b5..f42aabde9 100644 --- a/src/builders/ini.rs +++ b/src/builders/ini.rs @@ -218,7 +218,8 @@ impl AsRef for IniBuilder { } } -// Ensure the C buffer is properly deinitialized when the builder goes out of scope. +// Ensure the C buffer is properly deinitialized when the builder goes out of +// scope. impl Drop for IniBuilder { fn drop(&mut self) { if !self.value.is_null() { diff --git a/src/builders/sapi.rs b/src/builders/sapi.rs index 1de957209..a9fc8898f 100644 --- a/src/builders/sapi.rs +++ b/src/builders/sapi.rs @@ -168,7 +168,8 @@ impl SapiBuilder { /// /// # Parameters /// - /// * `func` - The function to be called when PHP gets an environment variable. + /// * `func` - The function to be called when PHP gets an environment + /// variable. pub fn getenv_function(mut self, func: SapiGetEnvFunc) -> Self { self.module.getenv = Some(func); self @@ -196,7 +197,8 @@ impl SapiBuilder { /// Sets the send headers function for this SAPI /// - /// This function is called once when all headers are finalized and ready to send. + /// This function is called once when all headers are finalized and ready to + /// send. /// /// # Arguments /// @@ -230,7 +232,8 @@ impl SapiBuilder { /// /// # Parameters /// - /// * `func` - The function to be called when PHP registers server variables. + /// * `func` - The function to be called when PHP registers server + /// variables. pub fn register_server_variables_function( mut self, func: SapiRegisterServerVariablesFunc, @@ -291,8 +294,8 @@ impl SapiBuilder { /// Sets the pre-request init function for this SAPI /// - /// This function is called before request activation and before POST data is read. - /// It is typically used for .user.ini processing. + /// This function is called before request activation and before POST data + /// is read. It is typically used for .user.ini processing. /// /// # Parameters /// @@ -455,7 +458,8 @@ pub type SapiGetUidFunc = extern "C" fn(uid: *mut uid_t) -> c_int; /// A function to be called when PHP gets the gid pub type SapiGetGidFunc = extern "C" fn(gid: *mut gid_t) -> c_int; -/// A function to be called before request activation (used for .user.ini processing) +/// A function to be called before request activation (used for .user.ini +/// processing) #[cfg(php85)] pub type SapiPreRequestInitFunc = extern "C" fn() -> c_int; @@ -485,8 +489,9 @@ mod test { extern "C" fn test_getenv(_name: *const c_char, _name_length: usize) -> *mut c_char { ptr::null_mut() } - // Note: C-variadic functions are unstable in Rust, so we can't test this properly - // extern "C" fn test_sapi_error(_type: c_int, _error_msg: *const c_char, _args: ...) {} + // Note: C-variadic functions are unstable in Rust, so we can't test this + // properly extern "C" fn test_sapi_error(_type: c_int, _error_msg: *const + // c_char, _args: ...) {} extern "C" fn test_send_header(_header: *mut sapi_header_struct, _server_context: *mut c_void) { } extern "C" fn test_send_headers(_sapi_headers: *mut sapi_headers_struct) -> c_int { @@ -633,9 +638,10 @@ mod test { ); } - // Note: Cannot test sapi_error_function because C-variadic functions are unstable in Rust - // The sapi_error field accepts a function with variadic arguments which cannot be - // created in stable Rust. However, the builder method itself works correctly. + // Note: Cannot test sapi_error_function because C-variadic functions are + // unstable in Rust The sapi_error field accepts a function with variadic + // arguments which cannot be created in stable Rust. However, the builder + // method itself works correctly. #[test] fn test_send_header_function() { diff --git a/src/closure.rs b/src/closure.rs index f184ff8f4..f58c9a6a0 100644 --- a/src/closure.rs +++ b/src/closure.rs @@ -116,7 +116,8 @@ impl Closure { /// function. /// /// If the class has already been built, this function returns early without - /// doing anything. This allows for safe repeated calls in test environments. + /// doing anything. This allows for safe repeated calls in test + /// environments. /// /// # Panics /// diff --git a/src/embed/mod.rs b/src/embed/mod.rs index 97df7e0aa..e0648cb3c 100644 --- a/src/embed/mod.rs +++ b/src/embed/mod.rs @@ -289,8 +289,9 @@ mod tests { #[test] fn test_eval_bailout() { Embed::run(|| { - // TODO: For PHP 8.5, this needs to be replaced, as `E_USER_ERROR` is deprecated. - // Currently, this seems to still be the best way to trigger a bailout. + // TODO: For PHP 8.5, this needs to be replaced, as `E_USER_ERROR` is + // deprecated. Currently, this seems to still be the best way + // to trigger a bailout. let result = Embed::eval("trigger_error(\"Fatal error\", E_USER_ERROR);"); assert!(result.is_err()); diff --git a/src/enum_.rs b/src/enum_.rs index 5868a876d..8bcbd6970 100644 --- a/src/enum_.rs +++ b/src/enum_.rs @@ -1,4 +1,5 @@ -//! This module defines the `PhpEnum` trait and related types for Rust enums that are exported to PHP. +//! This module defines the `PhpEnum` trait and related types for Rust enums +//! that are exported to PHP. use std::ptr; use crate::{ @@ -19,7 +20,8 @@ pub trait RegisteredEnum { /// # Errors /// - /// - [`Error::InvalidProperty`] if the enum does not have a case with the given name, an error is returned. + /// - [`Error::InvalidProperty`] if the enum does not have a case with the + /// given name, an error is returned. fn from_name(name: &str) -> Result where Self: Sized; @@ -125,7 +127,8 @@ impl EnumCase { } } -/// Represents the discriminant of an enum case in PHP, which can be either an integer or a string. +/// Represents the discriminant of an enum case in PHP, which can be either an +/// integer or a string. #[derive(Debug, PartialEq, Eq)] pub enum Discriminant { /// An integer discriminant. diff --git a/src/types/array/conversions/mod.rs b/src/types/array/conversions/mod.rs index fbcc4c7f0..4d4cb4086 100644 --- a/src/types/array/conversions/mod.rs +++ b/src/types/array/conversions/mod.rs @@ -1,8 +1,8 @@ //! Collection type conversions for `ZendHashTable`. //! -//! This module provides conversions between Rust collection types and PHP arrays -//! (represented as `ZendHashTable`). Each collection type has its own module for -//! better organization and maintainability. +//! This module provides conversions between Rust collection types and PHP +//! arrays (represented as `ZendHashTable`). Each collection type has its own +//! module for better organization and maintainability. //! //! ## Supported Collections //! diff --git a/src/types/callable.rs b/src/types/callable.rs index 6639309d4..1ddf13a9c 100644 --- a/src/types/callable.rs +++ b/src/types/callable.rs @@ -1,11 +1,14 @@ //! Types related to callables in PHP (anonymous functions, functions, etc). -use std::{convert::TryFrom, ops::Deref, ptr}; +use std::{convert::TryFrom, mem::MaybeUninit, ops::Deref, ptr}; use crate::{ convert::{FromZval, IntoZvalDyn}, error::{Error, Result}, - ffi::_call_user_function_impl, + ffi::{ + _call_user_function_impl, _zend_fcall_info_cache, zend_call_function, zend_fcall_info, + zend_is_callable_ex, + }, flags::DataType, zend::ExecutorGlobals, }; @@ -190,3 +193,449 @@ impl Deref for OwnedZval<'_> { self.as_ref() } } + +/// A cached callable that pre-computes function resolution for efficient +/// repeated calls. +/// +/// Unlike [`ZendCallable`], this type caches the function lookup information in +/// `zend_fcall_info` and `zend_fcall_info_cache` structures, avoiding the +/// overhead of function resolution on each call. This is particularly +/// beneficial when calling the same function multiple times, such as in +/// iterator callbacks or event handlers. +/// +/// # Performance +/// +/// For a function called N times: +/// - `ZendCallable`: O(N) function lookups +/// - `CachedCallable`: O(1) function lookup + O(N) direct calls +/// +/// # Example +/// +/// ```no_run +/// use ext_php_rs::types::CachedCallable; +/// +/// let mut callback = CachedCallable::try_from_name("strtoupper").unwrap(); +/// +/// // Each call reuses the cached function info +/// for word in ["hello", "world", "rust"] { +/// let result = callback.try_call(vec![&word]).unwrap(); +/// println!("Upper: {}", result.string().unwrap()); +/// } +/// ``` +pub struct CachedCallable { + /// The callable zval (keeps the callable alive) + callable: Zval, + /// Cached function call info + fci: zend_fcall_info, + /// Cached function call info cache + fcc: _zend_fcall_info_cache, +} + +impl std::fmt::Debug for CachedCallable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CachedCallable") + .field("callable", &self.callable) + .finish_non_exhaustive() + } +} + +impl CachedCallable { + /// Creates a new cached callable from a function name. + /// + /// This resolves the function once and caches the result for efficient + /// repeated calls. + /// + /// # Parameters + /// + /// * `name` - The name of the function to call. + /// + /// # Errors + /// + /// Returns an error if the function does not exist or is not callable. + /// + /// # Example + /// + /// ```no_run + /// use ext_php_rs::types::CachedCallable; + /// + /// let mut strpos = CachedCallable::try_from_name("strpos").unwrap(); + /// let result = strpos.try_call(vec![&"hello world", &"world"]).unwrap(); + /// assert_eq!(result.long(), Some(6)); + /// ``` + pub fn try_from_name(name: &str) -> Result { + let mut callable = Zval::new(); + callable.set_string(name, false)?; + Self::try_from_zval(callable) + } + + /// Creates a new cached callable from a zval. + /// + /// The zval can be a string (function name), array (class method), or + /// closure. + /// + /// # Parameters + /// + /// * `callable` - The zval representing the callable. + /// + /// # Errors + /// + /// Returns an error if the zval is not callable. + pub fn try_from_zval(callable: Zval) -> Result { + let mut fcc = MaybeUninit::<_zend_fcall_info_cache>::uninit(); + + // Check if callable and initialize the cache + let is_callable = unsafe { + zend_is_callable_ex( + ptr::from_ref(&callable).cast_mut(), + ptr::null_mut(), // object + 0, // check_flags + ptr::null_mut(), // callable_name (we don't need it) + fcc.as_mut_ptr(), + ptr::null_mut(), // error (we don't need detailed error) + ) + }; + + if !is_callable { + return Err(Error::Callable); + } + + // SAFETY: fcc was initialized by zend_is_callable_ex when it returned true + let fcc = unsafe { fcc.assume_init() }; + + // Initialize fci with the callable + // Note: We use shallow_clone to copy the callable zval into the fci structure + let fci = zend_fcall_info { + size: std::mem::size_of::(), + function_name: callable.shallow_clone(), + retval: ptr::null_mut(), + params: ptr::null_mut(), + object: fcc.object, + param_count: 0, + named_params: ptr::null_mut(), + }; + + Ok(Self { callable, fci, fcc }) + } + + /// Calls the cached callable with the given parameters. + /// + /// This method is optimized for repeated calls - the function lookup is + /// cached and only parameter binding and invocation occur on each call. + /// + /// # Parameters + /// + /// * `params` - A list of parameters to pass to the function. + /// + /// # Errors + /// + /// Returns an error if: + /// * The call fails + /// * An exception is thrown during execution + /// * Parameter conversion fails + /// + /// # Example + /// + /// ```no_run + /// use ext_php_rs::types::CachedCallable; + /// + /// let mut callback = CachedCallable::try_from_name("strtoupper").unwrap(); + /// + /// // Efficient repeated calls + /// let words = vec!["hello", "world", "rust"]; + /// for word in words { + /// let result = callback.try_call(vec![&word]).unwrap(); + /// println!("{}", result.string().unwrap()); + /// } + /// ``` + #[inline] + #[allow(clippy::cast_possible_truncation)] + pub fn try_call(&mut self, params: Vec<&dyn IntoZvalDyn>) -> Result { + let mut retval = Zval::new(); + + // Convert parameters to zvals + let params: Vec = params + .into_iter() + .map(|val| val.as_zval(false)) + .collect::>>()?; + + // Update fci with the current call parameters + self.fci.retval = &raw mut retval; + self.fci.params = params.as_ptr().cast_mut(); + self.fci.param_count = params.len() as u32; + + // Call the function using the cached info + let result = unsafe { zend_call_function(&raw mut self.fci, &raw mut self.fcc) }; + + // Reset fci pointers to avoid dangling references + self.fci.retval = ptr::null_mut(); + self.fci.params = ptr::null_mut(); + self.fci.param_count = 0; + + if result != 0 { + Err(Error::Callable) + } else if let Some(e) = ExecutorGlobals::take_exception() { + Err(Error::Exception(e)) + } else { + Ok(retval) + } + } + + /// Calls the cached callable with pre-converted zval parameters. + /// + /// This is the most efficient way to call a function when you already have + /// zval arguments, as it avoids any parameter conversion overhead. + /// + /// # Parameters + /// + /// * `params` - A slice of Zval parameters to pass to the function. + /// + /// # Errors + /// + /// Returns an error if the call fails or an exception is thrown. + /// + /// # Example + /// + /// ```no_run + /// use ext_php_rs::types::{CachedCallable, Zval}; + /// + /// let mut callback = CachedCallable::try_from_name("array_sum").unwrap(); + /// + /// let mut arr = Zval::new(); + /// // ... populate arr as a PHP array ... + /// + /// let result = callback.call_with_zvals(&[arr]).unwrap(); + /// ``` + #[inline] + #[allow(clippy::cast_possible_truncation)] + pub fn call_with_zvals(&mut self, params: &[Zval]) -> Result { + let mut retval = Zval::new(); + + // Update fci with the current call parameters + self.fci.retval = &raw mut retval; + self.fci.params = params.as_ptr().cast_mut(); + self.fci.param_count = params.len() as u32; + + // Call the function using the cached info + let result = unsafe { zend_call_function(&raw mut self.fci, &raw mut self.fcc) }; + + // Reset fci pointers + self.fci.retval = ptr::null_mut(); + self.fci.params = ptr::null_mut(); + self.fci.param_count = 0; + + if result != 0 { + Err(Error::Callable) + } else if let Some(e) = ExecutorGlobals::take_exception() { + Err(Error::Exception(e)) + } else { + Ok(retval) + } + } +} + +impl TryFrom for CachedCallable { + type Error = Error; + + fn try_from(value: Zval) -> Result { + CachedCallable::try_from_zval(value) + } +} + +impl TryFrom<&str> for CachedCallable { + type Error = Error; + + fn try_from(name: &str) -> Result { + CachedCallable::try_from_name(name) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + // Note: ZendCallable tests that create Zval require PHP runtime. + // These tests are marked with #[cfg(feature = "embed")] and use Embed::run(). + + #[test] + #[cfg(feature = "embed")] + fn test_zend_callable_new_non_callable() { + use crate::embed::Embed; + + Embed::run(|| { + let zval = Zval::new(); + let result = ZendCallable::new(&zval); + assert!(result.is_err()); + }); + } + + #[test] + #[cfg(feature = "embed")] + fn test_zend_callable_new_owned_non_callable() { + use crate::embed::Embed; + + Embed::run(|| { + let zval = Zval::new(); + let result = ZendCallable::new_owned(zval); + assert!(result.is_err()); + }); + } + + #[test] + #[cfg(feature = "embed")] + fn test_zend_callable_try_from_name() { + use crate::embed::Embed; + + Embed::run(|| { + let callable = ZendCallable::try_from_name("strtoupper"); + assert!(callable.is_ok()); + }); + } + + #[test] + #[cfg(feature = "embed")] + fn test_zend_callable_try_from_name_invalid() { + use crate::embed::Embed; + + Embed::run(|| { + let callable = ZendCallable::try_from_name("nonexistent_function_12345"); + assert!(callable.is_err()); + }); + } + + #[test] + #[cfg(feature = "embed")] + fn test_zend_callable_try_call() { + use crate::embed::Embed; + + Embed::run(|| { + let callable = ZendCallable::try_from_name("strtoupper").unwrap(); + let result = callable.try_call(vec![&"hello"]); + assert!(result.is_ok()); + let zval = result.unwrap(); + assert_eq!(zval.string().unwrap().clone(), "HELLO"); + }); + } + + // ========================================================================= + // CachedCallable tests + // ========================================================================= + + #[test] + #[cfg(feature = "embed")] + fn test_cached_callable_try_from_name() { + use crate::embed::Embed; + + Embed::run(|| { + let callable = CachedCallable::try_from_name("strtoupper"); + assert!(callable.is_ok()); + }); + } + + #[test] + #[cfg(feature = "embed")] + fn test_cached_callable_try_from_name_invalid() { + use crate::embed::Embed; + + Embed::run(|| { + let callable = CachedCallable::try_from_name("nonexistent_function_12345"); + assert!(callable.is_err()); + }); + } + + #[test] + #[cfg(feature = "embed")] + fn test_cached_callable_try_call() { + use crate::embed::Embed; + + Embed::run(|| { + let mut callable = CachedCallable::try_from_name("strtoupper").unwrap(); + let result = callable.try_call(vec![&"hello"]); + assert!(result.is_ok()); + let zval = result.unwrap(); + assert_eq!(zval.string().unwrap().clone(), "HELLO"); + }); + } + + #[test] + #[cfg(feature = "embed")] + fn test_cached_callable_multiple_calls() { + use crate::embed::Embed; + + Embed::run(|| { + let mut callable = CachedCallable::try_from_name("strlen").unwrap(); + + // Call multiple times to verify caching works + for (input, expected) in [("hello", 5), ("world!", 6), ("", 0), ("rust", 4)] { + let result = callable.try_call(vec![&input]).unwrap(); + assert_eq!(result.long().unwrap(), expected); + } + }); + } + + #[test] + #[cfg(feature = "embed")] + fn test_cached_callable_call_with_zvals() { + use crate::embed::Embed; + + Embed::run(|| { + let mut callable = CachedCallable::try_from_name("strlen").unwrap(); + + let mut arg = Zval::new(); + arg.set_string("test", false).unwrap(); + + let result = callable.call_with_zvals(&[arg]); + assert!(result.is_ok()); + assert_eq!(result.unwrap().long().unwrap(), 4); + }); + } + + #[test] + #[cfg(feature = "embed")] + fn test_cached_callable_try_from_zval() { + use crate::embed::Embed; + + Embed::run(|| { + let mut zval = Zval::new(); + zval.set_string("strtolower", false).unwrap(); + + let callable = CachedCallable::try_from_zval(zval); + assert!(callable.is_ok()); + + let mut callable = callable.unwrap(); + let result = callable.try_call(vec![&"HELLO"]).unwrap(); + assert_eq!(result.string().unwrap().clone(), "hello"); + }); + } + + #[test] + #[cfg(feature = "embed")] + fn test_cached_callable_try_from() { + use crate::embed::Embed; + + Embed::run(|| { + let mut zval = Zval::new(); + zval.set_string("abs", false).unwrap(); + + let callable: Result = zval.try_into(); + assert!(callable.is_ok()); + + let mut callable = callable.unwrap(); + let result = callable.try_call(vec![&(-42i64)]).unwrap(); + assert_eq!(result.long().unwrap(), 42); + }); + } + + #[test] + #[cfg(feature = "embed")] + fn test_cached_callable_debug() { + use crate::embed::Embed; + + Embed::run(|| { + let callable = CachedCallable::try_from_name("strlen").unwrap(); + let debug_str = format!("{callable:?}"); + assert!(debug_str.contains("CachedCallable")); + }); + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs index ffbf52bf3..ed86d2a05 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -14,7 +14,7 @@ mod string; mod zval; pub use array::{ArrayKey, ZendHashTable}; -pub use callable::ZendCallable; +pub use callable::{CachedCallable, ZendCallable}; pub use class_object::ZendClassObject; pub use iterable::Iterable; pub use iterator::ZendIterator; diff --git a/src/zend/ex.rs b/src/zend/ex.rs index 4c66b5b12..8ca9c6d47 100644 --- a/src/zend/ex.rs +++ b/src/zend/ex.rs @@ -1,7 +1,7 @@ use crate::ffi::{ZEND_MM_ALIGNMENT, ZEND_MM_ALIGNMENT_MASK, zend_execute_data}; use crate::{ - args::ArgParser, + args::{ArgParser, ArgZvals}, class::RegisteredClass, types::{ZendClassObject, ZendObject, Zval}, }; @@ -78,18 +78,18 @@ impl ExecuteData { pub fn parser_object(&mut self) -> (ArgParser<'_, '_>, Option<&mut ZendObject>) { // SAFETY: All fields of the `u2` union are the same type. let n_args = unsafe { self.This.u2.num_args }; - let mut args = vec![]; - for i in 0..n_args { + // Use stack-based storage for <= 8 arguments (the common case) + // This avoids heap allocation for most function calls + let args = ArgZvals::from_iter((0..n_args).map(|i| { // SAFETY: Function definition ensures arg lifetime doesn't exceed execution // data lifetime. - let arg = unsafe { self.zend_call_arg(i as usize) }; - args.push(arg); - } + unsafe { self.zend_call_arg(i as usize) } + })); let obj = self.This.object_mut(); - (ArgParser::new(args), obj) + (ArgParser::from_arg_zvals(args), obj) } /// Returns an [`ArgParser`] pre-loaded with the arguments contained inside @@ -208,6 +208,59 @@ impl ExecuteData { unsafe { self.prev_execute_data.as_ref() } } + /// Returns the number of arguments passed to this function call. + /// + /// This is useful for raw functions that need to know how many arguments + /// were passed without using the full argument parser. + #[inline] + #[must_use] + pub fn num_args(&self) -> u32 { + // SAFETY: All fields of the `u2` union are the same type. + unsafe { self.This.u2.num_args } + } + + /// Gets a reference to the argument at the given index (0-based). + /// + /// This is a low-level method for raw function implementations that need + /// direct access to arguments without type conversion overhead. For most + /// use cases, prefer using the [`ArgParser`] via [`parser()`]. + /// + /// # Safety + /// + /// The caller must ensure: + /// - `n` is less than [`num_args()`] + /// - The returned reference is not held longer than the function call + /// + /// # Example + /// + /// ```no_run + /// use ext_php_rs::{types::Zval, zend::ExecuteData}; + /// + /// fn raw_handler(ex: &mut ExecuteData, retval: &mut Zval) { + /// if ex.num_args() >= 1 { + /// if let Some(arg) = unsafe { ex.get_arg(0) } { + /// if let Some(n) = arg.long() { + /// retval.set_long(n + 1); + /// return; + /// } + /// } + /// } + /// retval.set_null(); + /// } + /// ``` + /// + /// [`parser()`]: #method.parser + /// [`num_args()`]: #method.num_args + #[inline] + #[must_use] + #[allow(clippy::mut_from_ref)] + pub unsafe fn get_arg<'a>(&self, n: usize) -> Option<&'a mut Zval> { + // SAFETY: Caller must ensure n < num_args() and reference is not held + // longer than the function call. The lifetime 'a is unbound from &self + // following the same pattern as zend_call_arg() for PHP internal reasons. + unsafe { self.zend_call_arg(n) } + } + /// Translation of macro `ZEND_CALL_ARG(call, n)` /// zend_compile.h:578 /// @@ -217,6 +270,7 @@ impl ExecuteData { /// Since this is a private method it's up to the caller to ensure the /// lifetime isn't exceeded. #[doc(hidden)] + #[inline] unsafe fn zend_call_arg<'a>(&self, n: usize) -> Option<&'a mut Zval> { let n = isize::try_from(n).expect("n is too large"); let ptr = unsafe { self.zend_call_var_num(n) }; @@ -259,4 +313,11 @@ mod tests { // Zend Engine v4.0.2, Copyright (c) Zend Technologies assert_eq!(ExecuteData::zend_call_frame_slot(), 5); } + + // Note: num_args() and get_arg() tests require a real PHP execution context + // and are tested indirectly through the raw function benchmarks and + // integration tests. The methods are simple wrappers around PHP + // internals and the implementation is straightforward enough that unit + // testing the edge cases isn't practical without a full PHP execution + // context. } diff --git a/tests/cached_callable.rs b/tests/cached_callable.rs new file mode 100644 index 000000000..ec40014ee --- /dev/null +++ b/tests/cached_callable.rs @@ -0,0 +1,204 @@ +//! Integration tests for `CachedCallable` functionality. + +#![cfg_attr(windows, feature(abi_vectorcall))] +#![cfg(feature = "embed")] +#![allow( + missing_docs, + clippy::needless_pass_by_value, + clippy::must_use_candidate +)] + +extern crate ext_php_rs; + +use ext_php_rs::builders::SapiBuilder; +use ext_php_rs::embed::{ext_php_rs_sapi_shutdown, ext_php_rs_sapi_startup}; +use ext_php_rs::ffi::{ + ZEND_RESULT_CODE_SUCCESS, php_module_shutdown, php_module_startup, php_request_shutdown, + php_request_startup, sapi_shutdown, sapi_startup, +}; +use ext_php_rs::prelude::*; +use ext_php_rs::types::CachedCallable; +use ext_php_rs::zend::try_catch_first; +use std::ffi::c_char; +use std::sync::Mutex; + +static TEST_MUTEX: Mutex<()> = Mutex::new(()); + +extern "C" fn output_handler(str: *const c_char, str_length: usize) -> usize { + let _ = unsafe { std::slice::from_raw_parts(str.cast::(), str_length) }; + str_length +} + +// ============================================================================ +// Test functions for CachedCallable +// ============================================================================ + +#[php_function] +pub fn cached_test_add(a: i64, b: i64) -> i64 { + a + b +} + +#[php_function] +pub fn cached_test_concat(a: String, b: String) -> String { + format!("{a}{b}") +} + +// ============================================================================ +// Module registration +// ============================================================================ + +#[php_module] +pub fn module(module: ModuleBuilder) -> ModuleBuilder { + module + .function(wrap_function!(cached_test_add)) + .function(wrap_function!(cached_test_concat)) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[test] +fn test_cached_callable_builtin() { + let _guard = TEST_MUTEX.lock().unwrap(); + + let builder = + SapiBuilder::new("cached-test1", "CachedCallable Test 1").ub_write_function(output_handler); + let sapi = builder.build().unwrap().into_raw(); + let module = get_module(); + + unsafe { + ext_php_rs_sapi_startup(); + sapi_startup(sapi); + php_module_startup(sapi, module); + } + + let result = unsafe { php_request_startup() }; + assert_eq!(result, ZEND_RESULT_CODE_SUCCESS); + + let _ = try_catch_first(|| { + // Test CachedCallable with a standard PHP function + let mut callable = CachedCallable::try_from_name("strtoupper").unwrap(); + + let result = callable.try_call(vec![&"hello"]).unwrap(); + assert_eq!(result.string().unwrap().clone(), "HELLO"); + + // Call again to test caching + let result = callable.try_call(vec![&"world"]).unwrap(); + assert_eq!(result.string().unwrap().clone(), "WORLD"); + }); + + unsafe { + php_request_shutdown(std::ptr::null_mut()); + php_module_shutdown(); + sapi_shutdown(); + ext_php_rs_sapi_shutdown(); + } +} + +#[test] +fn test_cached_callable_custom_function() { + let _guard = TEST_MUTEX.lock().unwrap(); + + let builder = + SapiBuilder::new("cached-test2", "CachedCallable Test 2").ub_write_function(output_handler); + let sapi = builder.build().unwrap().into_raw(); + let module = get_module(); + + unsafe { + ext_php_rs_sapi_startup(); + sapi_startup(sapi); + php_module_startup(sapi, module); + } + + let result = unsafe { php_request_startup() }; + assert_eq!(result, ZEND_RESULT_CODE_SUCCESS); + + let _ = try_catch_first(|| { + // Test CachedCallable with our custom function + let mut callable = CachedCallable::try_from_name("cached_test_add").unwrap(); + + let result = callable.try_call(vec![&10i64, &32i64]).unwrap(); + assert_eq!(result.long(), Some(42)); + + // Call multiple times to verify caching works + for i in 0..5 { + let result = callable + .try_call(vec![&i64::from(i), &i64::from(i)]) + .unwrap(); + assert_eq!(result.long(), Some(i64::from(i * 2))); + } + }); + + unsafe { + php_request_shutdown(std::ptr::null_mut()); + php_module_shutdown(); + sapi_shutdown(); + ext_php_rs_sapi_shutdown(); + } +} + +#[test] +fn test_cached_callable_string_function() { + let _guard = TEST_MUTEX.lock().unwrap(); + + let builder = + SapiBuilder::new("cached-test3", "CachedCallable Test 3").ub_write_function(output_handler); + let sapi = builder.build().unwrap().into_raw(); + let module = get_module(); + + unsafe { + ext_php_rs_sapi_startup(); + sapi_startup(sapi); + php_module_startup(sapi, module); + } + + let result = unsafe { php_request_startup() }; + assert_eq!(result, ZEND_RESULT_CODE_SUCCESS); + + let _ = try_catch_first(|| { + let mut callable = CachedCallable::try_from_name("cached_test_concat").unwrap(); + + let result = callable.try_call(vec![&"Hello, ", &"World!"]).unwrap(); + assert_eq!(result.string().unwrap().clone(), "Hello, World!"); + }); + + unsafe { + php_request_shutdown(std::ptr::null_mut()); + php_module_shutdown(); + sapi_shutdown(); + ext_php_rs_sapi_shutdown(); + } +} + +#[test] +fn test_cached_callable_invalid_name() { + let _guard = TEST_MUTEX.lock().unwrap(); + + let builder = + SapiBuilder::new("cached-test4", "CachedCallable Test 4").ub_write_function(output_handler); + let sapi = builder.build().unwrap().into_raw(); + let module = get_module(); + + unsafe { + ext_php_rs_sapi_startup(); + sapi_startup(sapi); + php_module_startup(sapi, module); + } + + let result = unsafe { php_request_startup() }; + assert_eq!(result, ZEND_RESULT_CODE_SUCCESS); + + let _ = try_catch_first(|| { + // Test with non-existent function + let result = CachedCallable::try_from_name("nonexistent_function_xyz"); + assert!(result.is_err()); + }); + + unsafe { + php_request_shutdown(std::ptr::null_mut()); + php_module_shutdown(); + sapi_shutdown(); + ext_php_rs_sapi_shutdown(); + } +} diff --git a/tests/raw_functions.rs b/tests/raw_functions.rs new file mode 100644 index 000000000..22776248e --- /dev/null +++ b/tests/raw_functions.rs @@ -0,0 +1,290 @@ +//! Integration tests for raw function attribute (#[php(raw)]) +//! and `ExecuteData::num_args()` / `get_arg()` methods. + +#![cfg_attr(windows, feature(abi_vectorcall))] +#![cfg(feature = "embed")] +#![allow( + missing_docs, + clippy::needless_pass_by_value, + clippy::must_use_candidate +)] + +extern crate ext_php_rs; + +use ext_php_rs::builders::SapiBuilder; +use ext_php_rs::embed::{Embed, ext_php_rs_sapi_shutdown, ext_php_rs_sapi_startup}; +use ext_php_rs::ffi::{ + ZEND_RESULT_CODE_SUCCESS, php_module_shutdown, php_module_startup, php_request_shutdown, + php_request_startup, sapi_shutdown, sapi_startup, +}; +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; +use ext_php_rs::zend::{ExecuteData, try_catch_first}; +use std::ffi::c_char; +use std::sync::Mutex; + +static TEST_MUTEX: Mutex<()> = Mutex::new(()); + +extern "C" fn output_handler(str: *const c_char, str_length: usize) -> usize { + let _ = unsafe { std::slice::from_raw_parts(str.cast::(), str_length) }; + str_length +} + +// ============================================================================ +// Standard #[php_function] for comparison +// ============================================================================ + +#[php_function] +pub fn raw_test_standard_add(a: i64, b: i64) -> i64 { + a + b +} + +// ============================================================================ +// Raw function implementations using #[php(raw)] +// ============================================================================ + +/// Raw function - zero overhead, direct access +#[php_function] +#[php(raw)] +pub fn raw_test_noop(_ex: &mut ExecuteData, retval: &mut Zval) { + retval.set_long(42); +} + +/// Raw function with argument access +#[php_function] +#[php(raw)] +pub fn raw_test_add(ex: &mut ExecuteData, retval: &mut Zval) { + let a = unsafe { ex.get_arg(0) } + .and_then(|zv| zv.long()) + .unwrap_or(0); + let b = unsafe { ex.get_arg(1) } + .and_then(|zv| zv.long()) + .unwrap_or(0); + retval.set_long(a + b); +} + +/// Raw function that uses `num_args()` +#[php_function] +#[php(raw)] +pub fn raw_test_count_args(ex: &mut ExecuteData, retval: &mut Zval) { + retval.set_long(i64::from(ex.num_args())); +} + +/// Raw function that sums all arguments +#[php_function] +#[php(raw)] +pub fn raw_test_sum_all(ex: &mut ExecuteData, retval: &mut Zval) { + let n = ex.num_args(); + let mut sum: i64 = 0; + for i in 0..n { + if let Some(zv) = unsafe { ex.get_arg(i as usize) } { + sum += zv.long().unwrap_or(0); + } + } + retval.set_long(sum); +} + +// ============================================================================ +// Module registration +// ============================================================================ + +#[php_module] +pub fn module(module: ModuleBuilder) -> ModuleBuilder { + module + .function(wrap_function!(raw_test_standard_add)) + .function(wrap_function!(raw_test_noop)) + .function(wrap_function!(raw_test_add)) + .function(wrap_function!(raw_test_count_args)) + .function(wrap_function!(raw_test_sum_all)) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[test] +fn test_raw_noop() { + let _guard = TEST_MUTEX.lock().unwrap(); + + let builder = + SapiBuilder::new("raw-test1", "Raw Function Test 1").ub_write_function(output_handler); + let sapi = builder.build().unwrap().into_raw(); + let module = get_module(); + + unsafe { + ext_php_rs_sapi_startup(); + sapi_startup(sapi); + php_module_startup(sapi, module); + } + + let result = unsafe { php_request_startup() }; + assert_eq!(result, ZEND_RESULT_CODE_SUCCESS); + + let _ = try_catch_first(|| { + let result = Embed::eval("raw_test_noop();"); + assert!(result.is_ok()); + let zval = result.unwrap(); + assert_eq!(zval.long(), Some(42)); + }); + + unsafe { + php_request_shutdown(std::ptr::null_mut()); + php_module_shutdown(); + sapi_shutdown(); + ext_php_rs_sapi_shutdown(); + } +} + +#[test] +fn test_raw_add() { + let _guard = TEST_MUTEX.lock().unwrap(); + + let builder = + SapiBuilder::new("raw-test2", "Raw Function Test 2").ub_write_function(output_handler); + let sapi = builder.build().unwrap().into_raw(); + let module = get_module(); + + unsafe { + ext_php_rs_sapi_startup(); + sapi_startup(sapi); + php_module_startup(sapi, module); + } + + let result = unsafe { php_request_startup() }; + assert_eq!(result, ZEND_RESULT_CODE_SUCCESS); + + let _ = try_catch_first(|| { + let result = Embed::eval("raw_test_add(10, 32);"); + assert!(result.is_ok()); + let zval = result.unwrap(); + assert_eq!(zval.long(), Some(42)); + }); + + unsafe { + php_request_shutdown(std::ptr::null_mut()); + php_module_shutdown(); + sapi_shutdown(); + ext_php_rs_sapi_shutdown(); + } +} + +#[test] +fn test_raw_count_args() { + let _guard = TEST_MUTEX.lock().unwrap(); + + let builder = + SapiBuilder::new("raw-test3", "Raw Function Test 3").ub_write_function(output_handler); + let sapi = builder.build().unwrap().into_raw(); + let module = get_module(); + + unsafe { + ext_php_rs_sapi_startup(); + sapi_startup(sapi); + php_module_startup(sapi, module); + } + + let result = unsafe { php_request_startup() }; + assert_eq!(result, ZEND_RESULT_CODE_SUCCESS); + + let _ = try_catch_first(|| { + // Test with 0 args + let result = Embed::eval("raw_test_count_args();"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().long(), Some(0)); + + // Test with 3 args + let result = Embed::eval("raw_test_count_args(1, 2, 3);"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().long(), Some(3)); + + // Test with 5 args + let result = Embed::eval("raw_test_count_args(1, 2, 3, 4, 5);"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().long(), Some(5)); + }); + + unsafe { + php_request_shutdown(std::ptr::null_mut()); + php_module_shutdown(); + sapi_shutdown(); + ext_php_rs_sapi_shutdown(); + } +} + +#[test] +fn test_raw_sum_all() { + let _guard = TEST_MUTEX.lock().unwrap(); + + let builder = + SapiBuilder::new("raw-test4", "Raw Function Test 4").ub_write_function(output_handler); + let sapi = builder.build().unwrap().into_raw(); + let module = get_module(); + + unsafe { + ext_php_rs_sapi_startup(); + sapi_startup(sapi); + php_module_startup(sapi, module); + } + + let result = unsafe { php_request_startup() }; + assert_eq!(result, ZEND_RESULT_CODE_SUCCESS); + + let _ = try_catch_first(|| { + // Test sum of multiple args + let result = Embed::eval("raw_test_sum_all(1, 2, 3, 4, 5);"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().long(), Some(15)); + + // Test with no args + let result = Embed::eval("raw_test_sum_all();"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().long(), Some(0)); + + // Test with single arg + let result = Embed::eval("raw_test_sum_all(42);"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().long(), Some(42)); + }); + + unsafe { + php_request_shutdown(std::ptr::null_mut()); + php_module_shutdown(); + sapi_shutdown(); + ext_php_rs_sapi_shutdown(); + } +} + +#[test] +fn test_standard_vs_raw_equivalence() { + let _guard = TEST_MUTEX.lock().unwrap(); + + let builder = + SapiBuilder::new("raw-test5", "Raw Function Test 5").ub_write_function(output_handler); + let sapi = builder.build().unwrap().into_raw(); + let module = get_module(); + + unsafe { + ext_php_rs_sapi_startup(); + sapi_startup(sapi); + php_module_startup(sapi, module); + } + + let result = unsafe { php_request_startup() }; + assert_eq!(result, ZEND_RESULT_CODE_SUCCESS); + + let _ = try_catch_first(|| { + // Both should return the same result + let standard = Embed::eval("raw_test_standard_add(10, 32);").unwrap(); + let raw = Embed::eval("raw_test_add(10, 32);").unwrap(); + + assert_eq!(standard.long(), raw.long()); + assert_eq!(standard.long(), Some(42)); + }); + + unsafe { + php_request_shutdown(std::ptr::null_mut()); + php_module_shutdown(); + sapi_shutdown(); + ext_php_rs_sapi_shutdown(); + } +} diff --git a/tests/sapi.rs b/tests/sapi.rs index 73136d4f6..688fd63dc 100644 --- a/tests/sapi.rs +++ b/tests/sapi.rs @@ -166,7 +166,7 @@ fn test_sapi_multithread() { Ok(zval) => { assert!(zval.is_string()); let string = zval.string().unwrap(); - let output = string.to_string(); + let output = string.clone(); assert_eq!(output, format!("Hello, thread-{i}!")); results.lock().unwrap().push((i, output)); diff --git a/tests/src/integration/mod.rs b/tests/src/integration/mod.rs index 60acb2958..97bd64bd8 100644 --- a/tests/src/integration/mod.rs +++ b/tests/src/integration/mod.rs @@ -38,7 +38,8 @@ mod test { command.arg("--release"); // Build features list dynamically based on compiled features - // Note: Using vec_init_then_push pattern here is intentional due to conditional compilation + // Note: Using vec_init_then_push pattern here is intentional due to conditional + // compilation #[allow(clippy::vec_init_then_push)] { let mut features = vec![];