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

extphprs / ext-php-rs / 25379640517

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

Pull #734

github

ptondereau
ci(build): skip cargo test on macOS until libphp is available

shivammathur/setup-php's Homebrew formulas (NTS php@x.y and
-debug-zts) do not ship a libphp shared library at
<php-config --prefix>/lib. With macos-latest now running macos-15
(ld-prime + chained fixups by default), the test binary aborts at
image load with "symbol not found in flat namespace" before any
test runs, since undefined PHP runtime data symbols can no longer
be deferred to runtime via -Wl,-undefined,dynamic_lookup.

The previous carve-out only excluded macOS + Rust nightly. Widen
it to all macOS variants. Build coverage on macOS still runs in
the step above. Restore the test step for macOS once a libphp is
wired into the runner (Homebrew --enable-embed build, or a custom
install step).
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