• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

extphprs / ext-php-rs / 25379163335

05 May 2026 01:25PM UTC coverage: 72.479% (+6.2%) from 66.241%
25379163335

Pull #734

github

web-flow
Merge 889e3cde4 into 0912e7c24
Pull Request #734: feat!: PHP 8 union, intersection, DNF, and class-union type hints

2871 of 3074 new or added lines in 21 files covered. (93.4%)

3 existing lines in 2 files now uncovered.

11514 of 15886 relevant lines covered (72.48%)

33.34 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.73
/crates/macros/src/function.rs
1
use std::collections::HashMap;
2
use std::str::FromStr;
3

4
use darling::{FromAttributes, ToTokens};
5
use ext_php_rs_types::PhpType;
6
use proc_macro2::{Ident, Span, TokenStream};
7
use quote::{format_ident, quote, quote_spanned};
8
use syn::punctuated::Punctuated;
9
use syn::spanned::Spanned as _;
10
use syn::token::Comma;
11
use syn::{Expr, FnArg, GenericArgument, ItemFn, LitStr, PatType, PathArguments, Type, TypePath};
12

13
use crate::helpers::get_docs;
14
use crate::parsing::{
15
    PhpNameContext, PhpRename, RenameRule, Visibility, ident_to_php_name, validate_php_name,
16
};
17
use crate::prelude::*;
18
use crate::syn_ext::DropLifetimes;
19

20
/// Checks if the return type is a reference to Self (`&Self` or `&mut Self`).
21
/// This is used to detect methods that return `$this` in PHP.
22
fn returns_self_ref(output: Option<&Type>) -> bool {
1,495✔
23
    let Some(ty) = output else {
1,495✔
24
        return false;
188✔
25
    };
26
    if let Type::Reference(ref_) = ty
1,307✔
27
        && let Type::Path(path) = &*ref_.elem
100✔
28
        && path.path.segments.len() == 1
100✔
29
        && let Some(segment) = path.path.segments.last()
100✔
30
    {
31
        return segment.ident == "Self";
100✔
32
    }
1,207✔
33
    false
1,207✔
34
}
1,495✔
35

36
/// Checks if the return type is `Self` (not a reference).
37
/// This is used to detect methods that return a new instance of the same class.
38
fn returns_self(output: Option<&Type>) -> bool {
263✔
39
    let Some(ty) = output else {
263✔
40
        return false;
×
41
    };
42
    if let Type::Path(path) = ty
263✔
43
        && path.path.segments.len() == 1
243✔
44
        && let Some(segment) = path.path.segments.last()
243✔
45
    {
46
        return segment.ident == "Self";
243✔
47
    }
20✔
48
    false
20✔
49
}
263✔
50

51
pub fn wrap(input: &syn::Path) -> Result<TokenStream> {
173✔
52
    let Some(func_name) = input.get_ident() else {
173✔
53
        bail!(input => "Pass a PHP function name into `wrap_function!()`.");
×
54
    };
55
    let builder_func = format_ident!("_internal_{func_name}");
173✔
56

57
    Ok(quote! {{
173✔
58
        (<#builder_func as ::ext_php_rs::internal::function::PhpFunction>::FUNCTION_ENTRY)()
173✔
59
    }})
173✔
60
}
173✔
61

62
#[derive(FromAttributes, Default, Debug)]
63
#[darling(default, attributes(php), forward_attrs(doc))]
64
struct PhpFunctionAttribute {
65
    #[darling(flatten)]
66
    rename: PhpRename,
67
    defaults: HashMap<Ident, Expr>,
68
    optional: Option<Ident>,
69
    returns: Option<LitStr>,
70
    vis: Option<Visibility>,
71
    attrs: Vec<syn::Attribute>,
72
}
73

74
#[derive(FromAttributes, Default, Debug)]
75
#[darling(default, attributes(php))]
76
pub struct PhpArgAttribute {
77
    pub types: Option<LitStr>,
78
}
79

80
/// Pulls a per-argument `#[php(types = "...")]` override off each `FnArg`,
81
/// returning a `Vec` aligned with the iteration order so it can be zipped
82
/// into [`Args::parse_from_fnargs`]. Receivers always yield `None`.
83
///
84
/// Each `#[php(types = "...")]` literal is parsed at expansion time via
85
/// [`parse_php_type_litstr`]; a parse failure surfaces as a `compile_error!`
86
/// spanned on the offending literal.
87
pub fn extract_arg_php_type_overrides<'a>(
363✔
88
    inputs: impl Iterator<Item = &'a FnArg>,
363✔
89
) -> Result<Vec<Option<PhpType>>> {
363✔
90
    let mut overrides = Vec::new();
363✔
91
    for fn_arg in inputs {
363✔
92
        match fn_arg {
309✔
93
            FnArg::Typed(pat_type) => {
221✔
94
                let attr = PhpArgAttribute::from_attributes(&pat_type.attrs)?;
221✔
95
                let parsed = match &attr.types {
221✔
96
                    Some(lit) => Some(parse_php_type_litstr(lit)?),
9✔
97
                    None => None,
212✔
98
                };
99
                overrides.push(parsed);
219✔
100
            }
101
            FnArg::Receiver(_) => overrides.push(None),
88✔
102
        }
103
    }
104
    Ok(overrides)
361✔
105
}
363✔
106

107
/// Removes the consumed `#[php(...)]` attributes from each typed `FnArg` so
108
/// the re-emitted `ItemFn` compiles cleanly under rustc. Mirrors the
109
/// function-level strip already done in [`parser`].
110
pub fn strip_per_arg_php_attrs(inputs: &mut Punctuated<FnArg, Comma>) {
361✔
111
    for fn_arg in inputs.iter_mut() {
361✔
112
        if let FnArg::Typed(pat_type) = fn_arg {
307✔
113
            pat_type.attrs.retain(|a| !a.path().is_ident("php"));
219✔
114
        }
88✔
115
    }
116
}
361✔
117

118
/// Parses the `LitStr` passed to `#[php(types = ...)]` / `#[php(returns =
119
/// ...)]` into a [`PhpType`] at macro-expansion time.
120
///
121
/// The parser is the same one the runtime would call — it lives in the
122
/// shared `ext-php-rs-types` crate so both this proc-macro and the runtime
123
/// crate share a single grammar. A parse failure becomes a `compile_error!`
124
/// spanned on the offending literal, so authors see the diagnostic at
125
/// `cargo build` instead of `cargo run`.
126
///
127
/// # Errors
128
///
129
/// Returns a [`syn::Error`] spanned on `lit` whenever
130
/// [`PhpType::from_str`] returns an error.
131
pub fn parse_php_type_litstr(lit: &LitStr) -> Result<PhpType> {
25✔
132
    let value = lit.value();
25✔
133
    PhpType::from_str(&value)
25✔
134
        .map_err(|err| syn::Error::new(lit.span(), format!("invalid PHP type {value:?}: {err}")))
25✔
135
}
25✔
136

137
pub fn parser(mut input: ItemFn) -> Result<TokenStream> {
215✔
138
    let php_attr = PhpFunctionAttribute::from_attributes(&input.attrs)?;
215✔
139
    input.attrs.retain(|attr| !attr.path().is_ident("php"));
215✔
140

141
    let arg_overrides = extract_arg_php_type_overrides(input.sig.inputs.iter())?;
215✔
142
    strip_per_arg_php_attrs(&mut input.sig.inputs);
213✔
143
    let returns_override = match &php_attr.returns {
213✔
144
        Some(lit) => Some(parse_php_type_litstr(lit)?),
5✔
145
        None => None,
208✔
146
    };
147

148
    let args = Args::parse_from_fnargs(
213✔
149
        input.sig.inputs.iter().zip(arg_overrides),
213✔
150
        php_attr.defaults,
213✔
NEW
151
    )?;
×
152
    if let Some(ReceiverArg { span, .. }) = args.receiver {
213✔
153
        bail!(span => "Receiver arguments are invalid on PHP functions. See `#[php_impl]`.");
×
154
    }
213✔
155

156
    let docs = get_docs(&php_attr.attrs)?;
213✔
157

158
    let func_name = php_attr
213✔
159
        .rename
213✔
160
        .rename(ident_to_php_name(&input.sig.ident), RenameRule::Snake);
213✔
161
    validate_php_name(&func_name, PhpNameContext::Function, input.sig.ident.span())?;
213✔
162
    let func = Function::new(
213✔
163
        &input.sig,
213✔
164
        func_name,
213✔
165
        args,
213✔
166
        php_attr.optional,
213✔
167
        returns_override,
213✔
168
        docs,
213✔
169
    );
170
    let function_impl = func.php_function_impl();
213✔
171

172
    Ok(quote! {
213✔
173
        #input
213✔
174
        #function_impl
213✔
175
    })
213✔
176
}
215✔
177

178
#[derive(Debug)]
179
pub struct Function<'a> {
180
    /// Identifier of the Rust function associated with the function.
181
    pub ident: &'a Ident,
182
    /// Name of the function in PHP.
183
    pub name: String,
184
    /// Function arguments.
185
    pub args: Args<'a>,
186
    /// Function outputs.
187
    pub output: Option<&'a Type>,
188
    /// The first optional argument of the function.
189
    pub optional: Option<Ident>,
190
    /// Optional `#[php(returns = "...")]` override for the registered PHP
191
    /// return type. When set, the macro emits the parsed [`PhpType`]
192
    /// directly via [`quote::ToTokens`] instead of deriving the type from
193
    /// the Rust signature via `IntoZval::TYPE`.
194
    pub returns_override: Option<PhpType>,
195
    /// Doc comments for the function.
196
    pub docs: Vec<String>,
197
}
198

199
#[derive(Debug)]
200
pub enum CallType<'a> {
201
    Function,
202
    Method {
203
        class: &'a syn::Path,
204
        receiver: MethodReceiver,
205
    },
206
}
207

208
/// Type of receiver on the method.
209
#[derive(Debug)]
210
pub enum MethodReceiver {
211
    /// Static method - has no receiver.
212
    Static,
213
    /// Class method, takes `&self` or `&mut self`.
214
    Class,
215
    /// Class method, takes `&mut ZendClassObject<Self>`.
216
    ZendClassObject,
217
}
218

219
impl<'a> Function<'a> {
220
    /// Parse a function.
221
    ///
222
    /// # Parameters
223
    ///
224
    /// * `sig` - Function signature.
225
    /// * `name` - Function name in PHP land.
226
    /// * `args` - Function arguments.
227
    /// * `optional` - The ident of the first optional argument.
228
    pub fn new(
361✔
229
        sig: &'a syn::Signature,
361✔
230
        name: String,
361✔
231
        args: Args<'a>,
361✔
232
        optional: Option<Ident>,
361✔
233
        returns_override: Option<PhpType>,
361✔
234
        docs: Vec<String>,
361✔
235
    ) -> Self {
361✔
236
        Self {
237
            ident: &sig.ident,
361✔
238
            name,
361✔
239
            args,
361✔
240
            output: match &sig.output {
361✔
241
                syn::ReturnType::Default => None,
51✔
242
                syn::ReturnType::Type(_, ty) => Some(&**ty),
310✔
243
            },
244
            optional,
361✔
245
            returns_override,
361✔
246
            docs,
361✔
247
        }
248
    }
361✔
249

250
    /// Generates an internal identifier for the function.
251
    pub fn internal_ident(&self) -> Ident {
213✔
252
        format_ident!("_internal_{}", &self.ident)
213✔
253
    }
213✔
254

255
    pub fn abstract_function_builder(&self) -> TokenStream {
16✔
256
        let name = &self.name;
16✔
257
        let (required, not_required) = self.args.split_args(self.optional.as_ref());
16✔
258

259
        // `entry` impl
260
        let required_args = required
16✔
261
            .iter()
16✔
262
            .map(TypedArg::arg_builder)
16✔
263
            .collect::<Vec<_>>();
16✔
264
        let not_required_args = not_required
16✔
265
            .iter()
16✔
266
            .map(TypedArg::arg_builder)
16✔
267
            .collect::<Vec<_>>();
16✔
268

269
        let returns = self.build_returns(None);
16✔
270
        let docs = if self.docs.is_empty() {
16✔
271
            quote! {}
14✔
272
        } else {
273
            let docs = &self.docs;
2✔
274
            quote! {
2✔
275
                .docs(&[#(#docs),*])
276
            }
277
        };
278

279
        quote! {
16✔
280
            ::ext_php_rs::builders::FunctionBuilder::new_abstract(#name)
281
            #(.arg(#required_args))*
282
            .not_required()
283
            #(.arg(#not_required_args))*
284
            #returns
285
            #docs
286
        }
287
    }
16✔
288

289
    /// Generates the function builder for the function.
290
    pub fn function_builder(&self, call_type: &CallType) -> TokenStream {
310✔
291
        let name = &self.name;
310✔
292
        let (required, not_required) = self.args.split_args(self.optional.as_ref());
310✔
293

294
        // `handler` impl
295
        let arg_declarations = self
310✔
296
            .args
310✔
297
            .typed
310✔
298
            .iter()
310✔
299
            .map(TypedArg::arg_declaration)
310✔
300
            .collect::<Vec<_>>();
310✔
301

302
        // `entry` impl
303
        let required_args = required
310✔
304
            .iter()
310✔
305
            .map(TypedArg::arg_builder)
310✔
306
            .collect::<Vec<_>>();
310✔
307
        let not_required_args = not_required
310✔
308
            .iter()
310✔
309
            .map(TypedArg::arg_builder)
310✔
310
            .collect::<Vec<_>>();
310✔
311

312
        let returns = self.build_returns(Some(call_type));
310✔
313
        let result = self.build_result(call_type, required, not_required);
310✔
314
        let docs = if self.docs.is_empty() {
310✔
315
            quote! {}
206✔
316
        } else {
317
            let docs = &self.docs;
104✔
318
            quote! {
104✔
319
                .docs(&[#(#docs),*])
320
            }
321
        };
322

323
        // Static methods cannot return &Self or &mut Self
324
        if returns_self_ref(self.output)
310✔
325
            && let CallType::Method {
326
                receiver: MethodReceiver::Static,
327
                ..
328
            } = call_type
3✔
329
            && let Some(output) = self.output
×
330
        {
331
            return quote_spanned! { output.span() =>
×
332
                compile_error!(
333
                    "Static methods cannot return `&Self` or `&mut Self`. \
334
                     Only instance methods can use fluent interface pattern returning `$this`."
335
                )
336
            };
337
        }
310✔
338

339
        // Check if this method returns &Self or &mut Self
340
        // In that case, we need to return `this` (the ZendClassObject) directly
341
        let returns_this = returns_self_ref(self.output)
310✔
342
            && matches!(
×
343
                call_type,
3✔
344
                CallType::Method {
345
                    receiver: MethodReceiver::Class | MethodReceiver::ZendClassObject,
346
                    ..
347
                }
348
            );
349

350
        let handler_body = if self.is_fast_path_eligible(call_type) {
310✔
351
            self.build_fast_handler_body(call_type)
299✔
352
        } else if returns_this {
11✔
353
            quote! {
×
354
                use ::ext_php_rs::convert::IntoZval;
355

356
                #(#arg_declarations)*
357
                #result
358

359
                // The method returns &Self or &mut Self, use `this` directly
360
                if let Err(e) = this.set_zval(retval, false) {
361
                    let e: ::ext_php_rs::exception::PhpException = e.into();
362
                    e.throw().expect("Failed to throw PHP exception.");
363
                }
364
            }
365
        } else {
366
            quote! {
11✔
367
                use ::ext_php_rs::convert::IntoZval;
368

369
                #(#arg_declarations)*
370
                let result = {
371
                    #result
372
                };
373

374
                if let Err(e) = result.set_zval(retval, false) {
375
                    let e: ::ext_php_rs::exception::PhpException = e.into();
376
                    e.throw().expect("Failed to throw PHP exception.");
377
                }
378
            }
379
        };
380

381
        quote! {
310✔
382
            ::ext_php_rs::builders::FunctionBuilder::new(#name, {
383
                ::ext_php_rs::zend_fastcall! {
384
                    #[allow(clippy::used_underscore_binding)]
385
                    extern fn handler(
386
                        ex: &mut ::ext_php_rs::zend::ExecuteData,
387
                        retval: &mut ::ext_php_rs::types::Zval,
388
                    ) {
389
                        use ::ext_php_rs::zend::try_catch;
390
                        use ::std::panic::AssertUnwindSafe;
391

392
                        // Wrap the handler body with try_catch to ensure Rust destructors
393
                        // are called if a bailout occurs (issue #537)
394
                        let catch_result = try_catch(AssertUnwindSafe(|| {
395
                            #handler_body
396
                        }));
397

398
                        // If there was a bailout, run BailoutGuard cleanups and re-trigger
399
                        if catch_result.is_err() {
400
                            ::ext_php_rs::zend::run_bailout_cleanups();
401
                            unsafe { ::ext_php_rs::zend::bailout(); }
402
                        }
403
                    }
404
                }
405
                handler
406
            })
407
            #(.arg(#required_args))*
408
            .not_required()
409
            #(.arg(#not_required_args))*
410
            #returns
411
            #docs
412
        }
413
    }
310✔
414

415
    fn build_returns(&self, call_type: Option<&CallType>) -> TokenStream {
326✔
416
        // `#[php(returns = "...")]` overrides whatever the Rust signature
417
        // would derive. Nullability is encoded inside the parsed `PhpType`
418
        // (e.g. `int|string|null`), so we pass `allow_null=false` here.
419
        if let Some(parsed) = &self.returns_override {
326✔
420
            return quote! {
9✔
421
                .returns(#parsed, false, false)
422
            };
423
        }
317✔
424

425
        let Some(output) = self.output.cloned() else {
317✔
426
            // PHP magic methods __destruct and __clone cannot have return types
427
            // (only applies to class methods, not standalone functions)
428
            if matches!(call_type, Some(CallType::Method { .. }))
47✔
429
                && (self.name == "__destruct" || self.name == "__clone")
20✔
430
            {
431
                return quote! {};
1✔
432
            }
50✔
433
            // No return type means void in PHP
434
            return quote! {
50✔
435
                .returns(::ext_php_rs::flags::DataType::Void, false, false)
436
            };
437
        };
438

439
        let mut output = output;
266✔
440
        output.drop_lifetimes();
266✔
441

442
        // If returning &Self or &mut Self from a method, use the class type
443
        // for return type information since we return `this` (ZendClassObject)
444
        if returns_self_ref(self.output)
266✔
445
            && let Some(CallType::Method { class, .. }) = call_type
3✔
446
        {
447
            return quote! {
3✔
448
                .returns(
449
                    <&mut ::ext_php_rs::types::ZendClassObject<#class> as ::ext_php_rs::convert::IntoZval>::php_type(),
450
                    false,
451
                    <&mut ::ext_php_rs::types::ZendClassObject<#class> as ::ext_php_rs::convert::IntoZval>::NULLABLE,
452
                )
453
            };
454
        }
263✔
455

456
        // If returning Self (new instance) from a method, replace Self with
457
        // the actual class type since Self won't resolve in generated code
458
        if returns_self(self.output)
263✔
459
            && let Some(CallType::Method { class, .. }) = call_type
1✔
460
        {
461
            return quote! {
1✔
462
                .returns(
463
                    <#class as ::ext_php_rs::convert::IntoZval>::php_type(),
464
                    false,
465
                    <#class as ::ext_php_rs::convert::IntoZval>::NULLABLE,
466
                )
467
            };
468
        }
262✔
469

470
        quote! {
262✔
471
            .returns(
472
                <#output as ::ext_php_rs::convert::IntoZval>::php_type(),
473
                false,
474
                <#output as ::ext_php_rs::convert::IntoZval>::NULLABLE,
475
            )
476
        }
477
    }
326✔
478

479
    fn build_result(
310✔
480
        &self,
310✔
481
        call_type: &CallType,
310✔
482
        required: &[TypedArg<'_>],
310✔
483
        not_required: &[TypedArg<'_>],
310✔
484
    ) -> TokenStream {
310✔
485
        let ident = self.ident;
310✔
486
        let required_arg_names: Vec<_> = required.iter().map(|arg| arg.name).collect();
310✔
487
        let not_required_arg_names: Vec<_> = not_required.iter().map(|arg| arg.name).collect();
310✔
488

489
        let variadic_bindings = self.args.typed.iter().filter_map(|arg| {
310✔
490
            if arg.variadic {
189✔
491
                let name = arg.name;
11✔
492
                let variadic_name = format_ident!("__variadic_{}", name);
11✔
493
                let clean_ty = arg.clean_ty();
11✔
494
                Some(quote! {
11✔
495
                    let #variadic_name = #name.variadic_vals::<#clean_ty>();
11✔
496
                })
11✔
497
            } else {
498
                None
178✔
499
            }
500
        });
189✔
501

502
        let arg_accessors = self.args.typed.iter().map(|arg| {
310✔
503
            arg.accessor(|e| {
189✔
504
                quote! {
171✔
505
                    #e.throw().expect("Failed to throw PHP exception.");
506
                    return;
507
                }
508
            })
171✔
509
        });
189✔
510

511
        // Check if this method returns &Self or &mut Self
512
        let returns_this = returns_self_ref(self.output);
310✔
513

514
        match call_type {
310✔
515
            CallType::Function => quote! {
213✔
516
                let parse = ex.parser()
517
                    #(.arg(&mut #required_arg_names))*
518
                    .not_required()
519
                    #(.arg(&mut #not_required_arg_names))*
520
                    .parse();
521
                if parse.is_err() {
522
                    return;
523
                }
524
                #(#variadic_bindings)*
525

526
                #ident(#({#arg_accessors}),*)
527
            },
528
            CallType::Method { class, receiver } => {
97✔
529
                let this = match receiver {
97✔
530
                    MethodReceiver::Static => quote! {
19✔
531
                        let parse = ex.parser();
532
                    },
533
                    MethodReceiver::ZendClassObject | MethodReceiver::Class => quote! {
78✔
534
                        let (parse, this) = ex.parser_method::<#class>();
535
                        let this = match this {
536
                            Some(this) => this,
537
                            None => {
538
                                ::ext_php_rs::exception::PhpException::default("Failed to retrieve reference to `$this`".into())
539
                                    .throw()
540
                                    .unwrap();
541
                                return;
542
                            }
543
                        };
544
                    },
545
                };
546

547
                // When returning &Self or &mut Self, discard the return value
548
                // (we'll use `this` directly in the handler)
549
                let call = match (receiver, returns_this) {
97✔
550
                    (MethodReceiver::Static, _) => {
551
                        quote! { #class::#ident(#({#arg_accessors}),*) }
19✔
552
                    }
553
                    (MethodReceiver::Class, true) => {
554
                        quote! { let _ = this.#ident(#({#arg_accessors}),*); }
3✔
555
                    }
556
                    (MethodReceiver::Class, false) => {
557
                        quote! { this.#ident(#({#arg_accessors}),*) }
71✔
558
                    }
559
                    (MethodReceiver::ZendClassObject, true) => {
560
                        // Explicit scope helps with mutable borrow lifetime when
561
                        // the method returns `&mut Self`
562
                        quote! {
×
563
                            {
564
                                let _ = #class::#ident(this, #({#arg_accessors}),*);
565
                            }
566
                        }
567
                    }
568
                    (MethodReceiver::ZendClassObject, false) => {
569
                        quote! { #class::#ident(this, #({#arg_accessors}),*) }
4✔
570
                    }
571
                };
572

573
                quote! {
97✔
574
                    #this
575
                    let parse_result = parse
576
                        #(.arg(&mut #required_arg_names))*
577
                        .not_required()
578
                        #(.arg(&mut #not_required_arg_names))*
579
                        .parse();
580
                    if parse_result.is_err() {
581
                        return;
582
                    }
583
                    #(#variadic_bindings)*
584

585
                    #call
586
                }
587
            }
588
        }
589
    }
310✔
590

591
    /// Whether this function is eligible for the zero-alloc fast path.
592
    /// Requires: no variadic parameters.
593
    fn is_fast_path_eligible(&self, call_type: &CallType) -> bool {
310✔
594
        let no_variadic = !self.args.typed.iter().any(|arg| arg.variadic);
310✔
595
        let supported_call_type = matches!(
310✔
596
            call_type,
97✔
597
            CallType::Function
598
                | CallType::Method {
599
                    receiver: MethodReceiver::Static
600
                        | MethodReceiver::Class
601
                        | MethodReceiver::ZendClassObject,
602
                    ..
603
                }
604
        );
605
        no_variadic && supported_call_type
310✔
606
    }
310✔
607

608
    /// Generates a zero-alloc fast path handler body.
609
    ///
610
    /// Instead of building `ArgParser` with `Vec`/`String` heap allocations,
611
    /// reads zvals directly from the call frame via pointer arithmetic
612
    /// and converts with `FromZvalMut` inline. Matches the pattern used by
613
    /// PHP's `ZEND_PARSE_PARAMETERS_START`/`END` C macros.
614
    fn restore_mutability(ty: &Type) -> Type {
17✔
615
        if let Type::Reference(r) = ty {
17✔
616
            let mut mref = r.clone();
13✔
617
            mref.mutability = Some(syn::token::Mut::default());
13✔
618
            Type::Reference(mref)
13✔
619
        } else {
620
            ty.clone()
4✔
621
        }
622
    }
17✔
623

624
    fn build_fast_arg_binding(i: usize, arg: &TypedArg<'_>, min_num_args: usize) -> TokenStream {
172✔
625
        let name = arg.name;
172✔
626
        let ty = arg.clean_ty();
172✔
627
        let zval_ident = format_ident!("__zval_{}", i);
172✔
628

629
        // parse_typed unwraps Option<T> → T and strips &mut → &.
630
        // Restore mutability for as_ref args so FromZvalMut resolves correctly.
631
        let convert_ty = if arg.as_ref {
172✔
632
            Self::restore_mutability(&ty)
16✔
633
        } else {
634
            ty.clone()
156✔
635
        };
636

637
        let binding_ty: Type = if !arg.nullable {
172✔
638
            ty.clone()
159✔
639
        } else if arg.as_ref {
13✔
640
            let mty = Self::restore_mutability(&ty);
1✔
641
            syn::parse_quote! { Option<#mty> }
1✔
642
        } else {
643
            syn::parse_quote! { Option<#ty> }
12✔
644
        };
645

646
        let read_zval = quote! {
172✔
647
            let #zval_ident = unsafe { ex.zend_call_arg(#i) };
648
            let Some(#zval_ident) = #zval_ident else { return; };
649
        };
650

651
        let from_zval = quote! {
172✔
652
            <#convert_ty as ::ext_php_rs::convert::FromZvalMut>::from_zval_mut(
653
                #zval_ident.dereference_mut()
654
            )
655
        };
656

657
        let convert = if arg.nullable {
172✔
658
            from_zval.clone()
13✔
659
        } else {
660
            quote! {
159✔
661
                match #from_zval {
662
                    Some(val) => val,
663
                    None => {
664
                        ::ext_php_rs::exception::PhpException::default(
665
                            concat!("Invalid value given for argument `", stringify!(#name), "`.").into()
666
                        ).throw().expect("Failed to throw PHP exception.");
667
                        return;
668
                    }
669
                }
670
            }
671
        };
672

673
        let throw_invalid = quote! {
172✔
674
            ::ext_php_rs::exception::PhpException::default(
675
                concat!("Invalid value given for argument `", stringify!(#name), "`.").into()
676
            ).throw().expect("Failed to throw PHP exception.");
677
            return;
678
        };
679

680
        let throw_null = quote! {
172✔
681
            ::ext_php_rs::exception::PhpException::new(
682
                concat!("Argument `$", stringify!(#name), "` must not be null").into(),
683
                0,
684
                ::ext_php_rs::zend::ce::type_error(),
685
            ).throw().expect("Failed to throw PHP exception.");
686
            return;
687
        };
688

689
        // Required arg — always present
690
        if i < min_num_args {
172✔
691
            return quote! {
154✔
692
                #read_zval
693
                let #name: #binding_ty = #convert;
694
            };
695
        }
18✔
696

697
        // Optional arg — may be omitted
698
        let fallback = match (&arg.default, arg.nullable) {
18✔
699
            (Some(expr), _) => quote! { #expr },
11✔
700
            (None, true) => quote! { None },
7✔
701
            (None, false) => throw_invalid.clone(),
×
702
        };
703

704
        // Non-nullable with default: explicit null must throw TypeError
705
        if !arg.nullable && arg.default.is_some() {
18✔
706
            return quote! {
7✔
707
                let #name: #binding_ty = if __num_args > #i {
708
                    #read_zval
709
                    if #zval_ident.is_null() { #throw_null }
710
                    #convert
711
                } else {
712
                    #fallback
713
                };
714
            };
715
        }
11✔
716

717
        quote! {
11✔
718
            let #name: #binding_ty = if __num_args > #i {
719
                #read_zval
720
                #convert
721
            } else {
722
                #fallback
723
            };
724
        }
725
    }
172✔
726

727
    fn build_fast_count_check(min_num_args: usize, max_num_args: usize) -> TokenStream {
299✔
728
        let min_u32 = u32::try_from(min_num_args).expect("too many args");
299✔
729
        let max_u32 = u32::try_from(max_num_args).expect("too many args");
299✔
730

731
        if min_num_args == max_num_args {
299✔
732
            quote! {
284✔
733
                let __num_args = unsafe { ex.This.u2.num_args } as usize;
734
                if __num_args != #min_num_args {
735
                    unsafe {
736
                        ::ext_php_rs::ffi::zend_wrong_parameters_count_error(#min_u32, #max_u32);
737
                    };
738
                    return;
739
                }
740
            }
741
        } else {
742
            quote! {
15✔
743
                let __num_args = unsafe { ex.This.u2.num_args } as usize;
744
                if !(#min_num_args..=#max_num_args).contains(&__num_args) {
745
                    unsafe {
746
                        ::ext_php_rs::ffi::zend_wrong_parameters_count_error(#min_u32, #max_u32);
747
                    };
748
                    return;
749
                }
750
            }
751
        }
752
    }
299✔
753

754
    fn build_fast_handler_body(&self, call_type: &CallType) -> TokenStream {
299✔
755
        let ident = self.ident;
299✔
756
        let (required, _not_required) = self.args.split_args(self.optional.as_ref());
299✔
757
        let min_num_args = required.len();
299✔
758
        let max_num_args = self.args.typed.len();
299✔
759

760
        // Arg count validation (matches zend_wrong_parameters_count_error)
761
        let count_check = Self::build_fast_count_check(min_num_args, max_num_args);
299✔
762

763
        let arg_bindings: Vec<TokenStream> = self
299✔
764
            .args
299✔
765
            .typed
299✔
766
            .iter()
299✔
767
            .enumerate()
299✔
768
            .map(|(i, arg)| Self::build_fast_arg_binding(i, arg, min_num_args))
299✔
769
            .collect();
299✔
770

771
        let arg_names: Vec<_> = self.args.typed.iter().map(|arg| arg.name).collect();
299✔
772

773
        let this_error = quote! {
299✔
774
            ::ext_php_rs::exception::PhpException::default(
775
                "Failed to retrieve reference to `$this`".into()
776
            ).throw().unwrap();
777
            return;
778
        };
779

780
        let returns_this = returns_self_ref(self.output);
299✔
781

782
        let (this_binding, call) = match call_type {
299✔
783
            CallType::Function => (quote! {}, quote! { #ident(#(#arg_names),*) }),
202✔
784
            CallType::Method {
785
                class,
19✔
786
                receiver: MethodReceiver::Static,
787
                ..
788
            } => (quote! {}, quote! { #class::#ident(#(#arg_names),*) }),
19✔
789
            CallType::Method {
790
                class,
74✔
791
                receiver: MethodReceiver::Class,
792
                ..
793
            } => (
794
                quote! {
74✔
795
                    let __this = match ex.get_object::<#class>() {
796
                        Some(v) => v,
797
                        None => { #this_error }
798
                    };
799
                },
800
                if returns_this {
74✔
801
                    quote! { let _ = __this.#ident(#(#arg_names),*); }
3✔
802
                } else {
803
                    quote! { __this.#ident(#(#arg_names),*) }
71✔
804
                },
805
            ),
806
            CallType::Method {
807
                class,
4✔
808
                receiver: MethodReceiver::ZendClassObject,
809
                ..
810
            } => (
811
                quote! {
4✔
812
                    let __this = match ex.get_object::<#class>() {
813
                        Some(v) => v,
814
                        None => { #this_error }
815
                    };
816
                },
817
                if returns_this {
4✔
818
                    quote! { { let _ = #class::#ident(__this, #(#arg_names),*); } }
×
819
                } else {
820
                    quote! { #class::#ident(__this, #(#arg_names),*) }
4✔
821
                },
822
            ),
823
        };
824

825
        if returns_this {
299✔
826
            quote! {
3✔
827
                use ::ext_php_rs::convert::{FromZvalMut, IntoZval};
828

829
                #count_check
830
                #(#arg_bindings)*
831
                #this_binding
832
                #call
833

834
                if let Err(e) = __this.set_zval(retval, false) {
835
                    let e: ::ext_php_rs::exception::PhpException = e.into();
836
                    e.throw().expect("Failed to throw PHP exception.");
837
                }
838
            }
839
        } else {
840
            quote! {
296✔
841
                use ::ext_php_rs::convert::{FromZvalMut, IntoZval};
842

843
                #count_check
844
                #(#arg_bindings)*
845
                #this_binding
846
                let __result = { #call };
847

848
                if let Err(e) = __result.set_zval(retval, false) {
849
                    let e: ::ext_php_rs::exception::PhpException = e.into();
850
                    e.throw().expect("Failed to throw PHP exception.");
851
                }
852
            }
853
        }
854
    }
299✔
855

856
    /// Generates a struct and impl for the `PhpFunction` trait.
857
    pub fn php_function_impl(&self) -> TokenStream {
213✔
858
        let internal_ident = self.internal_ident();
213✔
859
        let builder = self.function_builder(&CallType::Function);
213✔
860

861
        quote! {
213✔
862
            #[doc(hidden)]
863
            #[allow(non_camel_case_types)]
864
            struct #internal_ident;
865

866
            impl ::ext_php_rs::internal::function::PhpFunction for #internal_ident {
867
                const FUNCTION_ENTRY: fn() -> ::ext_php_rs::builders::FunctionBuilder<'static> = {
868
                    fn entry() -> ::ext_php_rs::builders::FunctionBuilder<'static>
869
                    {
870
                        #builder
871
                    }
872
                    entry
873
                };
874
            }
875
        }
876
    }
213✔
877

878
    /// Returns a constructor metadata object for this function. This doesn't
879
    /// check if the function is a constructor, however.
880
    pub fn constructor_meta(
35✔
881
        &self,
35✔
882
        class: &syn::Path,
35✔
883
        visibility: Option<&Visibility>,
35✔
884
    ) -> TokenStream {
35✔
885
        let ident = self.ident;
35✔
886
        let (required, not_required) = self.args.split_args(self.optional.as_ref());
35✔
887
        let required_args = required
35✔
888
            .iter()
35✔
889
            .map(TypedArg::arg_builder)
35✔
890
            .collect::<Vec<_>>();
35✔
891
        let not_required_args = not_required
35✔
892
            .iter()
35✔
893
            .map(TypedArg::arg_builder)
35✔
894
            .collect::<Vec<_>>();
35✔
895

896
        let required_arg_names: Vec<_> = required.iter().map(|arg| arg.name).collect();
35✔
897
        let not_required_arg_names: Vec<_> = not_required.iter().map(|arg| arg.name).collect();
35✔
898
        let arg_declarations = self
35✔
899
            .args
35✔
900
            .typed
35✔
901
            .iter()
35✔
902
            .map(TypedArg::arg_declaration)
35✔
903
            .collect::<Vec<_>>();
35✔
904
        let variadic_bindings = self.args.typed.iter().filter_map(|arg| {
35✔
905
            if arg.variadic {
19✔
906
                let name = arg.name;
×
907
                let variadic_name = format_ident!("__variadic_{}", name);
×
908
                let clean_ty = arg.clean_ty();
×
909
                Some(quote! {
×
910
                    let #variadic_name = #name.variadic_vals::<#clean_ty>();
×
911
                })
×
912
            } else {
913
                None
19✔
914
            }
915
        });
19✔
916
        let arg_accessors = self.args.typed.iter().map(|arg| {
35✔
917
            arg.accessor(
19✔
918
                |e| quote! { return ::ext_php_rs::class::ConstructorResult::Exception(#e); },
19✔
919
            )
920
        });
19✔
921
        let variadic = self.args.typed.iter().any(|arg| arg.variadic).then(|| {
35✔
922
            quote! {
×
923
                .variadic()
924
            }
925
        });
×
926
        let docs = &self.docs;
35✔
927
        let flags = visibility.option_tokens();
35✔
928

929
        quote! {
35✔
930
            ::ext_php_rs::class::ConstructorMeta {
931
                constructor: {
932
                    fn inner(ex: &mut ::ext_php_rs::zend::ExecuteData) -> ::ext_php_rs::class::ConstructorResult<#class> {
933
                        use ::ext_php_rs::zend::try_catch;
934
                        use ::std::panic::AssertUnwindSafe;
935

936
                        // Wrap the constructor body with try_catch to ensure Rust destructors
937
                        // are called if a bailout occurs (issue #537)
938
                        let catch_result = try_catch(AssertUnwindSafe(|| {
939
                            #(#arg_declarations)*
940
                            let parse = ex.parser()
941
                                #(.arg(&mut #required_arg_names))*
942
                                .not_required()
943
                                #(.arg(&mut #not_required_arg_names))*
944
                                #variadic
945
                                .parse();
946
                            if parse.is_err() {
947
                                return ::ext_php_rs::class::ConstructorResult::ArgError;
948
                            }
949
                            #(#variadic_bindings)*
950
                            #class::#ident(#({#arg_accessors}),*).into()
951
                        }));
952

953
                        // If there was a bailout, run BailoutGuard cleanups and re-trigger
954
                        match catch_result {
955
                            Ok(result) => result,
956
                            Err(_) => {
957
                                ::ext_php_rs::zend::run_bailout_cleanups();
958
                                unsafe { ::ext_php_rs::zend::bailout() }
959
                            }
960
                        }
961
                    }
962
                    inner
963
                },
964
                build_fn: {
965
                    fn inner(func: ::ext_php_rs::builders::FunctionBuilder) -> ::ext_php_rs::builders::FunctionBuilder {
966
                        func
967
                            .docs(&[#(#docs),*])
968
                            #(.arg(#required_args))*
969
                            .not_required()
970
                            #(.arg(#not_required_args))*
971
                            #variadic
972
                    }
973
                    inner
974
                },
975
                flags: #flags
976
            }
977
        }
978
    }
35✔
979
}
980

981
#[derive(Debug)]
982
pub struct ReceiverArg {
983
    pub _mutable: bool,
984
    pub span: Span,
985
}
986

987
#[derive(Debug)]
988
pub struct TypedArg<'a> {
989
    pub name: &'a Ident,
990
    pub ty: Type,
991
    pub nullable: bool,
992
    pub default: Option<Expr>,
993
    pub as_ref: bool,
994
    pub variadic: bool,
995
    /// Optional `#[php(types = "...")]` override for the registered PHP type
996
    /// of this argument. When set, the macro emits the parsed [`PhpType`]
997
    /// directly via [`quote::ToTokens`] instead of deriving the type from
998
    /// the Rust signature via `FromZvalMut::TYPE`.
999
    pub php_type_override: Option<PhpType>,
1000
}
1001

1002
#[derive(Debug)]
1003
pub struct Args<'a> {
1004
    pub receiver: Option<ReceiverArg>,
1005
    pub typed: Vec<TypedArg<'a>>,
1006
}
1007

1008
impl<'a> Args<'a> {
1009
    pub fn parse_from_fnargs(
361✔
1010
        args: impl Iterator<Item = (&'a FnArg, Option<PhpType>)>,
361✔
1011
        mut defaults: HashMap<Ident, Expr>,
361✔
1012
    ) -> Result<Self> {
361✔
1013
        let mut result = Self {
361✔
1014
            receiver: None,
361✔
1015
            typed: vec![],
361✔
1016
        };
361✔
1017
        for (arg, php_type_override) in args {
361✔
1018
            match arg {
307✔
1019
                FnArg::Receiver(receiver) => {
88✔
1020
                    if receiver.reference.is_none() {
88✔
1021
                        bail!(receiver => "PHP objects are heap-allocated and cannot be passed by value. Try using `&self` or `&mut self`.");
×
1022
                    } else if result.receiver.is_some() {
88✔
1023
                        bail!(receiver => "Too many receivers specified.")
×
1024
                    }
88✔
1025
                    result.receiver.replace(ReceiverArg {
88✔
1026
                        _mutable: receiver.mutability.is_some(),
88✔
1027
                        span: receiver.span(),
88✔
1028
                    });
88✔
1029
                }
1030
                FnArg::Typed(PatType { pat, ty, .. }) => {
219✔
1031
                    let syn::Pat::Ident(syn::PatIdent { ident, .. }) = &**pat else {
219✔
1032
                        bail!(pat => "Unsupported argument.");
×
1033
                    };
1034

1035
                    // If the variable is `&[&Zval]` treat it as the variadic argument.
1036
                    let default = defaults.remove(ident);
219✔
1037
                    let nullable = type_is_nullable(ty.as_ref())?;
219✔
1038
                    let (variadic, as_ref, ty) = Self::parse_typed(ty);
219✔
1039
                    result.typed.push(TypedArg {
219✔
1040
                        name: ident,
219✔
1041
                        ty,
219✔
1042
                        nullable,
219✔
1043
                        default,
219✔
1044
                        as_ref,
219✔
1045
                        variadic,
219✔
1046
                        php_type_override,
219✔
1047
                    });
219✔
1048
                }
1049
            }
1050
        }
1051
        Ok(result)
361✔
1052
    }
361✔
1053

1054
    fn parse_typed(ty: &Type) -> (bool, bool, Type) {
219✔
1055
        match ty {
219✔
1056
            Type::Reference(ref_) => {
67✔
1057
                let as_ref = ref_.mutability.is_some();
67✔
1058
                match ref_.elem.as_ref() {
67✔
1059
                    Type::Slice(slice) => (
11✔
1060
                        // TODO: Allow specifying the variadic type.
11✔
1061
                        slice.elem.to_token_stream().to_string() == "& Zval",
11✔
1062
                        as_ref,
11✔
1063
                        ty.clone(),
11✔
1064
                    ),
11✔
1065
                    _ => (false, as_ref, ty.clone()),
56✔
1066
                }
1067
            }
1068
            Type::Path(TypePath { path, .. }) => {
152✔
1069
                let mut as_ref = false;
152✔
1070

1071
                // PhpRef<'a> explicitly requires PHP pass-by-reference.
1072
                // Separated<'a> is handled by default (as_ref stays false).
1073
                if path
152✔
1074
                    .segments
152✔
1075
                    .last()
152✔
1076
                    .is_some_and(|seg| seg.ident == "PhpRef")
152✔
1077
                {
4✔
1078
                    as_ref = true;
4✔
1079
                }
148✔
1080

1081
                // For for types that are `Option<&mut T>` to turn them into
1082
                // `Option<&T>`, marking the Arg as as "passed by reference".
1083
                let ty = path
152✔
1084
                    .segments
152✔
1085
                    .last()
152✔
1086
                    .filter(|seg| seg.ident == "Option")
152✔
1087
                    .and_then(|seg| {
152✔
1088
                        if let PathArguments::AngleBracketed(args) = &seg.arguments {
14✔
1089
                            args.args
14✔
1090
                                .iter()
14✔
1091
                                .find(|arg| matches!(arg, GenericArgument::Type(_)))
14✔
1092
                                .and_then(|ga| match ga {
14✔
1093
                                    GenericArgument::Type(ty) => Some(match ty {
14✔
1094
                                        Type::Reference(r) => {
2✔
1095
                                            // Only mark as_ref for mutable references
1096
                                            // (Option<&mut T>), not immutable ones (Option<&T>)
1097
                                            as_ref = r.mutability.is_some();
2✔
1098
                                            let mut new_ref = r.clone();
2✔
1099
                                            new_ref.mutability = None;
2✔
1100
                                            Type::Reference(new_ref)
2✔
1101
                                        }
1102
                                        _ => ty.clone(),
12✔
1103
                                    }),
1104
                                    _ => None,
×
1105
                                })
14✔
1106
                        } else {
1107
                            None
×
1108
                        }
1109
                    })
14✔
1110
                    .unwrap_or_else(|| ty.clone());
152✔
1111
                (false, as_ref, ty.clone())
152✔
1112
            }
1113
            _ => (false, false, ty.clone()),
×
1114
        }
1115
    }
219✔
1116

1117
    /// Splits the typed arguments into two slices:
1118
    ///
1119
    /// 1. Required arguments.
1120
    /// 2. Non-required arguments.
1121
    ///
1122
    /// # Parameters
1123
    ///
1124
    /// * `optional` - The first optional argument. If [`None`], the optional
1125
    ///   arguments will be from the first optional argument (nullable or has
1126
    ///   default) after the last required argument to the end of the arguments.
1127
    pub fn split_args(&self, optional: Option<&Ident>) -> (&[TypedArg<'a>], &[TypedArg<'a>]) {
660✔
1128
        let mut mid = None;
660✔
1129
        for (i, arg) in self.typed.iter().enumerate() {
660✔
1130
            // An argument is optional if it's nullable (Option<T>) or has a default value.
1131
            let is_optional = arg.nullable || arg.default.is_some();
387✔
1132
            if let Some(optional) = optional {
387✔
1133
                if optional == arg.name {
6✔
1134
                    mid.replace(i);
2✔
1135
                }
4✔
1136
            } else if mid.is_none() && is_optional {
381✔
1137
                mid.replace(i);
33✔
1138
            } else if !is_optional {
348✔
1139
                mid.take();
342✔
1140
            }
342✔
1141
        }
1142
        match mid {
660✔
1143
            Some(mid) => (&self.typed[..mid], &self.typed[mid..]),
32✔
1144
            None => (&self.typed[..], &self.typed[0..0]),
628✔
1145
        }
1146
    }
660✔
1147
}
1148

1149
impl TypedArg<'_> {
1150
    /// Returns a 'clean type' with the lifetimes removed. This allows the type
1151
    /// to be used outside of the original function context.
1152
    fn clean_ty(&self) -> Type {
592✔
1153
        let mut ty = self.ty.clone();
592✔
1154
        ty.drop_lifetimes();
592✔
1155

1156
        // Variadic arguments are passed as &[&Zval], so we need to extract the
1157
        // inner type.
1158
        if self.variadic {
592✔
1159
            let Type::Reference(reference) = &ty else {
33✔
1160
                return ty;
×
1161
            };
1162

1163
            if let Type::Slice(inner) = &*reference.elem {
33✔
1164
                return *inner.elem.clone();
33✔
1165
            }
×
1166
        }
559✔
1167

1168
        ty
559✔
1169
    }
592✔
1170

1171
    /// Returns a token stream containing an argument declaration, where the
1172
    /// name of the variable holding the arg is the name of the argument.
1173
    fn arg_declaration(&self) -> TokenStream {
208✔
1174
        let name = self.name;
208✔
1175
        let val = self.arg_builder();
208✔
1176
        quote! {
208✔
1177
            let mut #name = #val;
1178
        }
1179
    }
208✔
1180

1181
    /// Returns a token stream containing the `Arg` definition to be passed to
1182
    /// `ext-php-rs`.
1183
    fn arg_builder(&self) -> TokenStream {
423✔
1184
        let name = ident_to_php_name(self.name);
423✔
1185
        let default = self.default.as_ref().map(|val| {
423✔
1186
            let val = expr_to_php_stub(val);
24✔
1187
            quote! {
24✔
1188
                .default(#val)
1189
            }
1190
        });
24✔
1191
        let as_ref = if self.as_ref {
423✔
1192
            Some(quote! { .as_ref() })
32✔
1193
        } else {
1194
            None
391✔
1195
        };
1196
        let variadic = self.variadic.then(|| quote! { .is_variadic() });
423✔
1197

1198
        // When `#[php(types = "...")]` is set, the override is the source of
1199
        // truth for the PHP type — including nullability. Other modifiers
1200
        // (default, as_ref, variadic) are about the argument-passing
1201
        // protocol, not the type, so they still apply.
1202
        if let Some(parsed) = &self.php_type_override {
423✔
1203
            return quote! {
14✔
1204
                ::ext_php_rs::args::Arg::new(#name, #parsed)
1205
                    #default
1206
                    #as_ref
1207
                    #variadic
1208
            };
1209
        }
409✔
1210

1211
        let ty = self.clean_ty();
409✔
1212
        let null = if self.nullable {
409✔
1213
            Some(quote! { .allow_null() })
28✔
1214
        } else {
1215
            None
381✔
1216
        };
1217
        quote! {
409✔
1218
            ::ext_php_rs::args::Arg::new(#name, <#ty as ::ext_php_rs::convert::FromZvalMut>::php_type())
1219
                #null
1220
                #default
1221
                #as_ref
1222
                #variadic
1223
        }
1224
    }
423✔
1225

1226
    /// Get the accessor used to access the value of the argument.
1227
    fn accessor(&self, bail_fn: impl Fn(TokenStream) -> TokenStream) -> TokenStream {
208✔
1228
        let name = self.name;
208✔
1229
        if let Some(default) = &self.default {
208✔
1230
            if self.nullable {
11✔
1231
                // For nullable types with defaults, null is acceptable
1232
                quote! {
4✔
1233
                    #name.val().unwrap_or(#default.into())
1234
                }
1235
            } else {
1236
                // For non-nullable types with defaults:
1237
                // - If argument was omitted: use default
1238
                // - If null was explicitly passed: throw TypeError
1239
                // - If a value was passed: try to convert it
1240
                let bail_null = bail_fn(quote! {
7✔
1241
                    ::ext_php_rs::exception::PhpException::new(
7✔
1242
                        concat!("Argument `$", stringify!(#name), "` must not be null").into(),
7✔
1243
                        0,
7✔
1244
                        ::ext_php_rs::zend::ce::type_error(),
7✔
1245
                    )
7✔
1246
                });
7✔
1247
                let bail_invalid = bail_fn(quote! {
7✔
1248
                    ::ext_php_rs::exception::PhpException::default(
7✔
1249
                        concat!("Invalid value given for argument `", stringify!(#name), "`.").into()
7✔
1250
                    )
7✔
1251
                });
7✔
1252
                quote! {
7✔
1253
                    match #name.zval() {
1254
                        Some(zval) if zval.is_null() => {
1255
                            // Null was explicitly passed to a non-nullable parameter
1256
                            #bail_null
1257
                        }
1258
                        Some(_) => {
1259
                            // A value was passed, try to convert it
1260
                            match #name.val() {
1261
                                Some(val) => val,
1262
                                None => {
1263
                                    #bail_invalid
1264
                                }
1265
                            }
1266
                        }
1267
                        None => {
1268
                            // Argument was omitted, use default
1269
                            #default.into()
1270
                        }
1271
                    }
1272
                }
1273
            }
1274
        } else if self.variadic {
197✔
1275
            let variadic_name = format_ident!("__variadic_{}", name);
11✔
1276
            quote! {
11✔
1277
                #variadic_name.as_slice()
1278
            }
1279
        } else if self.nullable {
186✔
1280
            // Originally I thought we could just use the below case for `null` options, as
1281
            // `val()` will return `Option<Option<T>>`, however, this isn't the case when
1282
            // the argument isn't given, as the underlying zval is null.
1283
            quote! {
10✔
1284
                #name.val()
1285
            }
1286
        } else {
1287
            let bail = bail_fn(quote! {
176✔
1288
                ::ext_php_rs::exception::PhpException::default(
176✔
1289
                    concat!("Invalid value given for argument `", stringify!(#name), "`.").into()
176✔
1290
                )
176✔
1291
            });
176✔
1292
            quote! {
176✔
1293
                match #name.val() {
1294
                    Some(val) => val,
1295
                    None => {
1296
                        #bail;
1297
                    }
1298
                }
1299
            }
1300
        }
1301
    }
208✔
1302
}
1303

1304
/// Converts a Rust expression to a PHP stub-compatible default value string.
1305
///
1306
/// This function handles common Rust patterns and converts them to valid PHP
1307
/// syntax for use in generated stub files:
1308
///
1309
/// - `None` → `"null"`
1310
/// - `Some(expr)` → converts the inner expression
1311
/// - `42`, `3.14` → numeric literals as-is
1312
/// - `true`/`false` → as-is
1313
/// - `"string"` → `"string"`
1314
/// - `"string".to_string()` or `String::from("string")` → `"string"`
1315
fn expr_to_php_stub(expr: &Expr) -> String {
42✔
1316
    match expr {
42✔
1317
        // Handle None -> null
1318
        Expr::Path(path) => {
7✔
1319
            let path_str = path.path.to_token_stream().to_string();
7✔
1320
            if path_str == "None" {
7✔
1321
                "null".to_string()
7✔
1322
            } else if path_str == "true" || path_str == "false" {
×
1323
                path_str
×
1324
            } else {
1325
                // For other paths (constants, etc.), use the raw representation
1326
                path_str
×
1327
            }
1328
        }
1329

1330
        // Handle Some(expr) -> convert inner expression
1331
        Expr::Call(call) => {
3✔
1332
            if let Expr::Path(func_path) = &*call.func {
3✔
1333
                let func_name = func_path.path.to_token_stream().to_string();
3✔
1334

1335
                // Some(value) -> convert inner value
1336
                if func_name == "Some"
3✔
1337
                    && let Some(arg) = call.args.first()
3✔
1338
                {
1339
                    return expr_to_php_stub(arg);
3✔
1340
                }
×
1341

1342
                // String::from("...") -> "..."
1343
                if (func_name == "String :: from" || func_name == "String::from")
×
1344
                    && let Some(arg) = call.args.first()
×
1345
                {
1346
                    return expr_to_php_stub(arg);
×
1347
                }
×
1348
            }
×
1349

1350
            // Default: use raw representation
1351
            expr.to_token_stream().to_string()
×
1352
        }
1353

1354
        // Handle method calls like "string".to_string()
1355
        Expr::MethodCall(method_call) => {
2✔
1356
            let method_name = method_call.method.to_string();
2✔
1357

1358
            // "...".to_string() or "...".to_owned() or "...".into() -> "..."
1359
            if method_name == "to_string" || method_name == "to_owned" || method_name == "into" {
2✔
1360
                return expr_to_php_stub(&method_call.receiver);
2✔
1361
            }
×
1362

1363
            // Default: use raw representation
1364
            expr.to_token_stream().to_string()
×
1365
        }
1366

1367
        // String literals -> keep as-is (already valid PHP)
1368
        Expr::Lit(lit) => match &lit.lit {
28✔
1369
            syn::Lit::Str(s) => format!(
2✔
1370
                "\"{}\"",
1371
                s.value().replace('\\', "\\\\").replace('"', "\\\"")
2✔
1372
            ),
1373
            // Use base10_digits() to strip Rust type suffixes like _usize, _i32, etc.
1374
            syn::Lit::Int(i) => i.base10_digits().to_string(),
22✔
1375
            syn::Lit::Float(f) => f.base10_digits().to_string(),
4✔
1376
            syn::Lit::Bool(b) => if b.value { "true" } else { "false" }.to_string(),
×
1377
            syn::Lit::Char(c) => format!("\"{}\"", c.value()),
×
1378
            _ => expr.to_token_stream().to_string(),
×
1379
        },
1380

1381
        // Handle arrays: [] or vec![]
1382
        Expr::Array(arr) => {
×
1383
            if arr.elems.is_empty() {
×
1384
                "[]".to_string()
×
1385
            } else {
1386
                let elems: Vec<String> = arr.elems.iter().map(expr_to_php_stub).collect();
×
1387
                format!("[{}]", elems.join(", "))
×
1388
            }
1389
        }
1390

1391
        // Handle vec![] macro
1392
        Expr::Macro(m) => {
×
1393
            let macro_name = m.mac.path.to_token_stream().to_string();
×
1394
            if macro_name == "vec" {
×
1395
                let tokens = m.mac.tokens.to_string();
×
1396
                if tokens.trim().is_empty() {
×
1397
                    return "[]".to_string();
×
1398
                }
×
1399
            }
×
1400
            // Default: use raw representation
1401
            expr.to_token_stream().to_string()
×
1402
        }
1403

1404
        // Handle unary expressions like -42
1405
        Expr::Unary(unary) => {
2✔
1406
            let inner = expr_to_php_stub(&unary.expr);
2✔
1407
            format!("{}{}", unary.op.to_token_stream(), inner)
2✔
1408
        }
1409

1410
        // Default: use raw representation
1411
        _ => expr.to_token_stream().to_string(),
×
1412
    }
1413
}
42✔
1414

1415
/// Returns true if the given type is nullable in PHP (i.e., it's an
1416
/// `Option<T>`).
1417
///
1418
/// Note: Having a default value does NOT make a type nullable. A parameter with
1419
/// a default value is optional (can be omitted), but passing `null` explicitly
1420
/// should still be rejected unless the type is `Option<T>`.
1421
// TODO(david): Eventually move to compile-time constants for this (similar to
1422
// FromZval::NULLABLE).
1423
pub fn type_is_nullable(ty: &Type) -> Result<bool> {
219✔
1424
    Ok(match ty {
219✔
1425
        Type::Path(path) => path
152✔
1426
            .path
152✔
1427
            .segments
152✔
1428
            .iter()
152✔
1429
            .next_back()
152✔
1430
            .is_some_and(|seg| seg.ident == "Option"),
152✔
1431
        Type::Reference(_) => false, /* Reference cannot be nullable unless */
67✔
1432
        // wrapped in `Option` (in that case it'd be a Path).
1433
        _ => bail!(ty => "Unsupported argument type."),
×
1434
    })
1435
}
219✔
1436

1437
#[cfg(test)]
1438
mod tests {
1439
    use super::*;
1440

1441
    #[test]
1442
    fn test_expr_to_php_stub_strips_numeric_suffixes() {
1✔
1443
        // Test integer suffixes are stripped (issue #492)
1444
        let expr: Expr = syn::parse_quote!(42_usize);
1✔
1445
        assert_eq!(expr_to_php_stub(&expr), "42");
1✔
1446

1447
        let expr: Expr = syn::parse_quote!(42_i32);
1✔
1448
        assert_eq!(expr_to_php_stub(&expr), "42");
1✔
1449

1450
        let expr: Expr = syn::parse_quote!(42_u64);
1✔
1451
        assert_eq!(expr_to_php_stub(&expr), "42");
1✔
1452

1453
        // Test float suffixes are stripped
1454
        let expr: Expr = syn::parse_quote!(3.14_f64);
1✔
1455
        assert_eq!(expr_to_php_stub(&expr), "3.14");
1✔
1456

1457
        let expr: Expr = syn::parse_quote!(3.14_f32);
1✔
1458
        assert_eq!(expr_to_php_stub(&expr), "3.14");
1✔
1459

1460
        // Test literals without suffixes still work
1461
        let expr: Expr = syn::parse_quote!(42);
1✔
1462
        assert_eq!(expr_to_php_stub(&expr), "42");
1✔
1463

1464
        let expr: Expr = syn::parse_quote!(3.14);
1✔
1465
        assert_eq!(expr_to_php_stub(&expr), "3.14");
1✔
1466
    }
1✔
1467

1468
    #[test]
1469
    fn test_expr_to_php_stub_negative_numbers() {
1✔
1470
        let expr: Expr = syn::parse_quote!(-42_i32);
1✔
1471
        assert_eq!(expr_to_php_stub(&expr), "-42");
1✔
1472

1473
        let expr: Expr = syn::parse_quote!(-3.14_f64);
1✔
1474
        assert_eq!(expr_to_php_stub(&expr), "-3.14");
1✔
1475
    }
1✔
1476

1477
    #[test]
1478
    fn test_expr_to_php_stub_none_and_some() {
1✔
1479
        let expr: Expr = syn::parse_quote!(None);
1✔
1480
        assert_eq!(expr_to_php_stub(&expr), "null");
1✔
1481

1482
        let expr: Expr = syn::parse_quote!(Some(42_usize));
1✔
1483
        assert_eq!(expr_to_php_stub(&expr), "42");
1✔
1484
    }
1✔
1485

1486
    #[test]
1487
    fn parse_php_type_litstr_accepts_primitive_union() {
1✔
1488
        let lit: LitStr = syn::parse_quote!("int|string|null");
1✔
1489
        let parsed = parse_php_type_litstr(&lit).expect("primitive union parses");
1✔
1490
        assert_eq!(format!("{parsed}"), "int|string|null");
1✔
1491
    }
1✔
1492

1493
    #[test]
1494
    fn parse_php_type_litstr_accepts_class_union() {
1✔
1495
        let lit: LitStr = syn::parse_quote!("\\Foo|\\Bar");
1✔
1496
        let parsed = parse_php_type_litstr(&lit).expect("class union parses");
1✔
1497
        assert_eq!(format!("{parsed}"), "\\Foo|\\Bar");
1✔
1498
    }
1✔
1499

1500
    #[test]
1501
    fn parse_php_type_litstr_accepts_intersection() {
1✔
1502
        let lit: LitStr = syn::parse_quote!("\\Countable&\\Traversable");
1✔
1503
        let parsed = parse_php_type_litstr(&lit).expect("intersection parses");
1✔
1504
        assert_eq!(format!("{parsed}"), "\\Countable&\\Traversable");
1✔
1505
    }
1✔
1506

1507
    #[test]
1508
    fn parse_php_type_litstr_accepts_dnf() {
1✔
1509
        let lit: LitStr = syn::parse_quote!("(\\A&\\B)|\\C");
1✔
1510
        let parsed = parse_php_type_litstr(&lit).expect("DNF parses");
1✔
1511
        assert_eq!(format!("{parsed}"), "(\\A&\\B)|\\C");
1✔
1512
    }
1✔
1513

1514
    #[test]
1515
    fn parse_php_type_litstr_rejects_empty() {
1✔
1516
        let lit: LitStr = syn::parse_quote!("");
1✔
1517
        let err = parse_php_type_litstr(&lit).unwrap_err();
1✔
1518
        let msg = err.to_string();
1✔
1519
        assert!(msg.contains("empty"), "unexpected error message: {msg}");
1✔
1520
    }
1✔
1521

1522
    #[test]
1523
    fn parse_php_type_litstr_rejects_double_pipe() {
1✔
1524
        // The parser rejects `||` because the empty alternative between
1525
        // pipes is not a legal PHP type term.
1526
        let lit: LitStr = syn::parse_quote!("int||string");
1✔
1527
        let err = parse_php_type_litstr(&lit).unwrap_err();
1✔
1528
        let msg = err.to_string();
1✔
1529
        assert!(
1✔
1530
            msg.contains("empty term"),
1✔
1531
            "unexpected error message: {msg}"
1532
        );
1533
    }
1✔
1534

1535
    #[test]
1536
    fn parse_php_type_litstr_rejects_class_nullable_shorthand() {
1✔
1537
        // `?Foo` was previously accepted by the lightweight syntactic check
1538
        // and only failed at extension load. With the parser running at
1539
        // expansion time, the rejection now spans the LitStr at compile time.
1540
        let lit: LitStr = syn::parse_quote!("?Foo");
1✔
1541
        let err = parse_php_type_litstr(&lit).unwrap_err();
1✔
1542
        let msg = err.to_string();
1✔
1543
        assert!(
1✔
1544
            msg.contains("class-side nullable"),
1✔
1545
            "unexpected error message: {msg}"
1546
        );
1547
    }
1✔
1548

1549
    #[test]
1550
    fn parser_strips_per_arg_php_attrs_from_emitted_fn() {
1✔
1551
        // Regression guard for PR #637: `#[php(types = ...)]` on a parameter
1552
        // must be removed from the re-emitted ItemFn so rustc never sees the
1553
        // unknown attribute.
1554
        let input: ItemFn = syn::parse_quote! {
1✔
1555
            pub fn foo(
1556
                #[php(types = "int|string")] _value: i64,
1557
            ) -> i64 {
1558
                _value
1559
            }
1560
        };
1561
        let output = parser(input).expect("parser should succeed").to_string();
1✔
1562
        assert!(
1✔
1563
            !output.contains("# [php"),
1✔
1564
            "expected #[php(...)] stripped from emitted fn, output: {output}"
1565
        );
1566
    }
1✔
1567

1568
    #[test]
1569
    fn parser_emits_compile_time_phptype_for_typed_override() {
1✔
1570
        let input: ItemFn = syn::parse_quote! {
1✔
1571
            pub fn foo(
1572
                #[php(types = "int|string")] _value: i64,
1573
            ) -> i64 {
1574
                _value
1575
            }
1576
        };
1577
        let output = parser(input).expect("parser should succeed").to_string();
1✔
1578
        // No more `from_str` runtime call: the macro emits the parsed
1579
        // `PhpType::Union(vec![DataType::Long, DataType::String])` literal.
1580
        assert!(
1✔
1581
            !output.contains("from_str"),
1✔
1582
            "expected NO runtime from_str call, output: {output}"
1583
        );
1584
        assert!(
1✔
1585
            output.contains("PhpType :: Union"),
1✔
1586
            "expected literal Union variant, output: {output}"
1587
        );
1588
        assert!(
1✔
1589
            output.contains("DataType :: Long"),
1✔
1590
            "expected DataType::Long in expansion, output: {output}"
1591
        );
1592
        assert!(
1✔
1593
            output.contains("DataType :: String"),
1✔
1594
            "expected DataType::String in expansion, output: {output}"
1595
        );
1596
    }
1✔
1597

1598
    #[test]
1599
    fn parser_emits_compile_time_phptype_for_returns_override() {
1✔
1600
        let input: ItemFn = syn::parse_quote! {
1✔
1601
            #[php(returns = "int|string|null")]
1602
            pub fn foo() -> i64 { 0 }
1603
        };
1604
        let output = parser(input).expect("parser should succeed").to_string();
1✔
1605
        assert!(
1✔
1606
            !output.contains("from_str"),
1✔
1607
            "expected NO runtime from_str call for returns, output: {output}"
1608
        );
1609
        assert!(
1✔
1610
            output.contains("PhpType :: Union"),
1✔
1611
            "expected literal Union variant in returns, output: {output}"
1612
        );
1613
        assert!(
1✔
1614
            output.contains("DataType :: Null"),
1✔
1615
            "expected DataType::Null in expansion, output: {output}"
1616
        );
1617
    }
1✔
1618

1619
    #[test]
1620
    fn parser_rejects_invalid_per_arg_litstr() {
1✔
1621
        // Compile-time grammar rejection — the parser refuses an empty
1622
        // term between two pipes, surfaced as a `compile_error!` spanned on
1623
        // the LitStr.
1624
        let input: ItemFn = syn::parse_quote! {
1✔
1625
            pub fn foo(
1626
                #[php(types = "int||string")] _value: i64,
1627
            ) -> i64 {
1628
                _value
1629
            }
1630
        };
1631
        let err = parser(input).unwrap_err();
1✔
1632
        assert!(
1✔
1633
            err.to_string().contains("empty term"),
1✔
1634
            "unexpected error: {err}",
1635
        );
1636
    }
1✔
1637

1638
    #[test]
1639
    fn parser_rejects_class_nullable_shorthand_at_compile_time() {
1✔
1640
        // `?Foo` used to pass the syntactic check and panic at extension
1641
        // load. Now the parser runs at expansion time, so the diagnostic
1642
        // appears at `cargo build`.
1643
        let input: ItemFn = syn::parse_quote! {
1✔
1644
            pub fn foo(
1645
                #[php(types = "?Foo")] _value: i64,
1646
            ) -> i64 {
1647
                _value
1648
            }
1649
        };
1650
        let err = parser(input).unwrap_err();
1✔
1651
        assert!(
1✔
1652
            err.to_string().contains("class-side nullable"),
1✔
1653
            "unexpected error: {err}",
1654
        );
1655
    }
1✔
1656
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc