Skip to main content

ext_php_rs/
closure.rs

1//! Types and functions used for exporting Rust closures to PHP.
2
3use crate::{
4    args::{Arg, ArgParser},
5    builders::{ClassBuilder, FunctionBuilder},
6    class::{ClassEntryInfo, ClassMetadata, RegisteredClass},
7    convert::{FromZval, IntoZval},
8    describe::DocComments,
9    exception::PhpException,
10    flags::{DataType, MethodFlags},
11    types::Zval,
12    zend::ExecuteData,
13    zend_fastcall,
14};
15
16/// Class entry and handlers for Rust closures.
17static CLOSURE_META: ClassMetadata<Closure> = ClassMetadata::new(&[]);
18
19/// Wrapper around a Rust closure, which can be exported to PHP.
20///
21/// Closures can have up to 8 parameters, all must implement [`FromZval`], and
22/// can return anything that implements [`IntoZval`]. Closures must have a
23/// static lifetime, and therefore cannot modify any `self` references.
24///
25/// Internally, closures are implemented as a PHP class. A class `RustClosure`
26/// is registered with an `__invoke` method:
27///
28/// ```php
29/// <?php
30///
31/// class RustClosure {
32///     public function __invoke(...$args): mixed {
33///         // ...
34///     }
35/// }
36/// ```
37///
38/// The Rust closure is then double boxed, firstly as a `Box<dyn Fn(...) ->
39/// ...>` (depending on the signature of the closure) and then finally boxed as
40/// a `Box<dyn PhpClosure>`. This is a workaround, as `PhpClosure` is not
41/// generically implementable on types that implement `Fn(T, ...) -> Ret`. Make
42/// a suggestion issue if you have a better idea of implementing this!.
43///
44/// When the `__invoke` method is called from PHP, the `invoke` method is called
45/// on the `dyn PhpClosure`\ trait object, and from there everything is
46/// basically the same as a regular PHP function.
47pub struct Closure(Box<dyn PhpClosure>);
48
49unsafe impl Send for Closure {}
50unsafe impl Sync for Closure {}
51
52impl Closure {
53    /// Wraps a [`Fn`] or [`FnMut`] Rust closure into a type which can be
54    /// returned to PHP.
55    ///
56    /// The closure can accept up to 8 arguments which implement [`IntoZval`],
57    /// and can return any type which implements [`FromZval`]. The closure
58    /// must have a static lifetime, so cannot reference `self`.
59    ///
60    /// # Parameters
61    ///
62    /// * `func` - The closure to wrap. Should be boxed in the form `Box<dyn
63    ///   Fn[Mut](...) -> ...>`.
64    ///
65    /// # Example
66    ///
67    /// ```rust,no_run
68    /// use ext_php_rs::closure::Closure;
69    ///
70    /// let closure = Closure::wrap(Box::new(|name| {
71    ///     format!("Hello {}", name)
72    /// }) as Box<dyn Fn(String) -> String>);
73    /// ```
74    pub fn wrap<T>(func: T) -> Self
75    where
76        T: PhpClosure + 'static,
77    {
78        Self(Box::new(func) as Box<dyn PhpClosure>)
79    }
80
81    /// Wraps a [`FnOnce`] Rust closure into a type which can be returned to
82    /// PHP. If the closure is called more than once from PHP, an exception
83    /// is thrown.
84    ///
85    /// The closure can accept up to 8 arguments which implement [`IntoZval`],
86    /// and can return any type which implements [`FromZval`]. The closure
87    /// must have a static lifetime, so cannot reference `self`.
88    ///
89    /// # Parameters
90    ///
91    /// * `func` - The closure to wrap. Should be boxed in the form `Box<dyn
92    ///   FnOnce(...) -> ...>`.
93    ///
94    /// # Example
95    ///
96    /// ```rust,no_run
97    /// use ext_php_rs::closure::Closure;
98    ///
99    /// let name: String = "Hello world".into();
100    /// let closure = Closure::wrap_once(Box::new(|| {
101    ///     name
102    /// }) as Box<dyn FnOnce() -> String>);
103    /// ```
104    pub fn wrap_once<T>(func: T) -> Self
105    where
106        T: PhpOnceClosure + 'static,
107    {
108        func.into_closure()
109    }
110
111    /// Builds the class entry for [`Closure`], registering it with PHP. This
112    /// function should only be called once inside your module startup
113    /// function.
114    ///
115    /// If the class has already been built, this function returns early without
116    /// doing anything. This allows for safe repeated calls in test
117    /// environments.
118    ///
119    /// # Panics
120    ///
121    /// Panics if the `RustClosure` PHP class cannot be registered.
122    #[expect(
123        clippy::expect_used,
124        reason = "the RustClosure class is registered once during MINIT"
125    )]
126    pub fn build() {
127        if CLOSURE_META.has_ce() {
128            return;
129        }
130
131        ClassBuilder::new("RustClosure")
132            .method(
133                FunctionBuilder::new("__invoke", Self::invoke)
134                    .not_required()
135                    .arg(Arg::new("args", DataType::Mixed).is_variadic())
136                    .returns(DataType::Mixed, false, true),
137                MethodFlags::Public,
138            )
139            .object_override::<Self>()
140            .registration(|ce, arg_info| {
141                CLOSURE_META.set_ce(ce);
142                CLOSURE_META.set_arg_info(arg_info);
143            })
144            .register()
145            .expect("Failed to build `RustClosure` PHP class.");
146    }
147
148    zend_fastcall! {
149        /// External function used by the Zend interpreter to call the closure.
150        extern "C" fn invoke(ex: &mut ExecuteData, ret: &mut Zval) {
151            crate::zend::run_handler(std::panic::AssertUnwindSafe(|| {
152                let (parser, this) = ex.parser_method::<Self>();
153                let Some(this) = this else {
154                    PhpException::from_message(
155                        "Rust closure invoked on an object that is not a RustClosure".into(),
156                    )
157                    .throw();
158                    return;
159                };
160
161                this.0.invoke(parser, ret);
162            }));
163        }
164    }
165}
166
167impl RegisteredClass for Closure {
168    const CLASS_NAME: &'static str = "RustClosure";
169
170    const BUILDER_MODIFIER: Option<fn(ClassBuilder) -> ClassBuilder> = None;
171    const EXTENDS: Option<ClassEntryInfo> = None;
172    const IMPLEMENTS: &'static [ClassEntryInfo] = &[];
173
174    fn get_metadata() -> &'static ClassMetadata<Self> {
175        &CLOSURE_META
176    }
177
178    fn method_builders() -> Vec<(FunctionBuilder<'static>, MethodFlags)> {
179        unimplemented!()
180    }
181
182    fn constructor() -> Option<crate::class::ConstructorMeta<Self>> {
183        None
184    }
185
186    fn constants() -> &'static [(
187        &'static str,
188        &'static dyn crate::convert::IntoZvalDyn,
189        DocComments,
190        crate::flags::ConstantFlags,
191    )] {
192        unimplemented!()
193    }
194}
195
196class_derives!(Closure);
197
198/// Implemented on types which can be used as PHP closures.
199///
200/// Types must implement the `invoke` function which will be called when the
201/// closure is called from PHP. Arguments must be parsed from the
202/// [`ExecuteData`] and the return value is returned through the [`Zval`].
203///
204/// This trait is automatically implemented on functions with up to 8
205/// parameters.
206#[allow(clippy::missing_safety_doc)]
207pub unsafe trait PhpClosure {
208    /// Invokes the closure.
209    fn invoke<'a>(&'a mut self, parser: ArgParser<'a, '_>, ret: &mut Zval);
210}
211
212/// Implemented on [`FnOnce`] types which can be used as PHP closures. See
213/// [`Closure`].
214///
215/// Internally, this trait should wrap the [`FnOnce`] closure inside a [`FnMut`]
216/// closure, and prevent the user from calling the closure more than once.
217pub trait PhpOnceClosure {
218    /// Converts the Rust [`FnOnce`] closure into a [`FnMut`] closure, and then
219    /// into a PHP closure.
220    fn into_closure(self) -> Closure;
221}
222
223unsafe impl<R> PhpClosure for Box<dyn Fn() -> R>
224where
225    R: IntoZval,
226{
227    fn invoke(&mut self, _: ArgParser, ret: &mut Zval) {
228        if let Err(e) = self().set_zval(ret, false) {
229            PhpException::from_message(format!("Failed to return closure result to PHP: {e}"))
230                .throw();
231        }
232    }
233}
234
235unsafe impl<R> PhpClosure for Box<dyn FnMut() -> R>
236where
237    R: IntoZval,
238{
239    fn invoke(&mut self, _: ArgParser, ret: &mut Zval) {
240        if let Err(e) = self().set_zval(ret, false) {
241            PhpException::from_message(format!("Failed to return closure result to PHP: {e}"))
242                .throw();
243        }
244    }
245}
246
247impl<R> PhpOnceClosure for Box<dyn FnOnce() -> R>
248where
249    R: IntoZval + 'static,
250{
251    fn into_closure(self) -> Closure {
252        let mut this = Some(self);
253
254        Closure::wrap(Box::new(move || {
255            let Some(this) = this.take() else {
256                PhpException::from_message(
257                    "Attempted to call `FnOnce` closure more than once.".into(),
258                )
259                .throw();
260                return Option::<R>::None;
261            };
262
263            Some(this())
264        }) as Box<dyn FnMut() -> Option<R>>)
265    }
266}
267
268macro_rules! php_closure_impl {
269    ($($gen: ident),*) => {
270        php_closure_impl!(Fn; $($gen),*);
271        php_closure_impl!(FnMut; $($gen),*);
272
273        impl<$($gen),*, Ret> PhpOnceClosure for Box<dyn FnOnce($($gen),*) -> Ret>
274        where
275            $(for<'a> $gen: FromZval<'a> + 'static,)*
276            Ret: IntoZval + 'static,
277        {
278            fn into_closure(self) -> Closure {
279                let mut this = Some(self);
280
281                Closure::wrap(Box::new(move |$($gen),*| {
282                    let Some(this) = this.take() else {
283                        PhpException::from_message(
284                            "Attempted to call `FnOnce` closure more than once.".into(),
285                        )
286                        .throw();
287                        return Option::<Ret>::None;
288                    };
289
290                    Some(this($($gen),*))
291                }) as Box<dyn FnMut($($gen),*) -> Option<Ret>>)
292            }
293        }
294    };
295
296    ($fnty: ident; $($gen: ident),*) => {
297        unsafe impl<$($gen),*, Ret> PhpClosure for Box<dyn $fnty($($gen),*) -> Ret>
298        where
299            $(for<'a> $gen: FromZval<'a>,)*
300            Ret: IntoZval
301        {
302            fn invoke(&mut self, parser: ArgParser, ret: &mut Zval) {
303                $(
304                    let mut $gen = Arg::new(stringify!($gen), $gen::TYPE);
305                )*
306
307                let parser = parser
308                    $(.arg(&mut $gen))*
309                    .parse();
310
311                if parser.is_err() {
312                    return;
313                }
314
315                let result = self(
316                    $(
317                        match $gen.consume() {
318                            Ok(val) => val,
319                            _ => {
320                                PhpException::from_message(concat!("Invalid parameter type for `", stringify!($gen), "`.").into()).throw();
321                                return;
322                            }
323                        }
324                    ),*
325                );
326
327                if let Err(e) = result.set_zval(ret, false) {
328                    PhpException::from_message(format!("Failed to return closure result to PHP: {}", e)).throw();
329                }
330            }
331        }
332    };
333}
334
335php_closure_impl!(A);
336php_closure_impl!(A, B);
337php_closure_impl!(A, B, C);
338php_closure_impl!(A, B, C, D);
339php_closure_impl!(A, B, C, D, E);
340php_closure_impl!(A, B, C, D, E, F);
341php_closure_impl!(A, B, C, D, E, F, G);
342php_closure_impl!(A, B, C, D, E, F, G, H);