From 0a478eda890e1c6fbd09a37232a7bd679a2ea168 Mon Sep 17 00:00:00 2001 From: Vasily Zorin Date: Tue, 23 Dec 2025 15:14:16 +0700 Subject: [PATCH 1/2] chore(tools): update_lib_docs.sh compatibility --- tools/update_lib_docs.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/update_lib_docs.sh b/tools/update_lib_docs.sh index 9c23ac61d..be810b98b 100755 --- a/tools/update_lib_docs.sh +++ b/tools/update_lib_docs.sh @@ -54,4 +54,4 @@ update_docs "enum" update_docs "interface" # Format to remove trailing whitespace -rustup run nightly rustfmt crates/macros/src/lib.rs +rustup run nightly rustfmt --edition 2024 crates/macros/src/lib.rs From 6d96b6ea385286af498a244090cbac4c4d60f4f7 Mon Sep 17 00:00:00 2001 From: Vasily Zorin Date: Tue, 23 Dec 2025 23:29:50 +0700 Subject: [PATCH 2/2] feat(types): union types, intersection, DNF #199 --- allowed_bindings.rs | 4 + crates/macros/src/function.rs | 403 ++++++++++++++++++++- crates/macros/src/impl_.rs | 14 +- crates/macros/src/interface.rs | 11 + crates/macros/src/lib.rs | 129 +++++++ crates/macros/src/parsing.rs | 15 +- docsrs_bindings.rs | 9 + guide/src/macros/function.md | 124 +++++++ guide/src/types/index.md | 13 + src/args.rs | 379 +++++++++++++++++++- src/ffi.rs | 2 + src/wrapper.c | 4 + src/wrapper.h | 1 + src/zend/_type.rs | 493 +++++++++++++++++++++++++- tests/Cargo.toml | 3 + tests/build.rs | 88 +++++ tests/src/integration/class/class.php | 67 ++++ tests/src/integration/class/mod.rs | 22 ++ tests/src/integration/mod.rs | 1 + tests/src/integration/union/mod.rs | 203 +++++++++++ tests/src/integration/union/union.php | 263 ++++++++++++++ tests/src/lib.rs | 1 + 22 files changed, 2224 insertions(+), 25 deletions(-) create mode 100644 tests/build.rs create mode 100644 tests/src/integration/union/mod.rs create mode 100644 tests/src/integration/union/union.php diff --git a/allowed_bindings.rs b/allowed_bindings.rs index b1596ad11..1d9584f84 100644 --- a/allowed_bindings.rs +++ b/allowed_bindings.rs @@ -246,6 +246,10 @@ bind! { _ZEND_IS_VARIADIC_BIT, _ZEND_SEND_MODE_SHIFT, _ZEND_TYPE_NULLABLE_BIT, + _ZEND_TYPE_LIST_BIT, + _ZEND_TYPE_UNION_BIT, + _ZEND_TYPE_INTERSECTION_BIT, + zend_type_list, ts_rsrc_id, _ZEND_TYPE_NAME_BIT, _ZEND_TYPE_LITERAL_NAME_BIT, diff --git a/crates/macros/src/function.rs b/crates/macros/src/function.rs index 6c8ba077e..6fe9845df 100644 --- a/crates/macros/src/function.rs +++ b/crates/macros/src/function.rs @@ -84,6 +84,14 @@ pub fn parser(mut input: ItemFn) -> Result { let func = Function::new(&input.sig, func_name, args, php_attr.optional, docs); let function_impl = func.php_function_impl(); + // Strip #[php(...)] attributes from function parameters before emitting output + // (must be done after function_impl is generated since func borrows from input) + for arg in &mut input.sig.inputs { + if let FnArg::Typed(pat_type) = arg { + pat_type.attrs.retain(|attr| !attr.path().is_ident("php")); + } + } + Ok(quote! { #input #function_impl @@ -602,6 +610,31 @@ pub struct ReceiverArg { pub span: Span, } +/// Represents a single element in a DNF type - either a simple class or an +/// intersection group. +#[derive(Debug, Clone)] +pub enum TypeGroup { + /// A single class/interface type: `ArrayAccess` + Single(String), + /// An intersection of class/interface types: `Countable&Traversable` + Intersection(Vec), +} + +/// Represents a complex PHP type declaration parsed from `#[php(type = +/// "...")]`. +#[derive(Debug, Clone)] +pub enum PhpTypeDecl { + /// Union of primitive types: int|string|null + PrimitiveUnion(Vec), + /// Intersection of class/interface types: Countable&Traversable + Intersection(Vec), + /// Union of class types: Foo|Bar + ClassUnion(Vec), + /// DNF (Disjunctive Normal Form) type: `(A&B)|C|D` or `(A&B)|(C&D)` + /// e.g., `(A&B)|C` becomes `vec![Intersection(["A", "B"]), Single("C")]` + Dnf(Vec), +} + #[derive(Debug)] pub struct TypedArg<'a> { pub name: &'a Ident, @@ -610,6 +643,9 @@ pub struct TypedArg<'a> { pub default: Option, pub as_ref: bool, pub variadic: bool, + /// PHP type declaration from `#[php(type = "...")]` or `#[php(union = + /// "...")]` + pub php_type: Option, } #[derive(Debug)] @@ -640,11 +676,14 @@ impl<'a> Args<'a> { span: receiver.span(), }); } - FnArg::Typed(PatType { pat, ty, .. }) => { + FnArg::Typed(PatType { pat, ty, attrs, .. }) => { let syn::Pat::Ident(syn::PatIdent { ident, .. }) = &**pat else { bail!(pat => "Unsupported argument."); }; + // Parse #[php(type = "...")] or #[php(union = "...")] attribute if present + let php_type = Self::parse_type_attr(attrs)?; + // If the variable is `&[&Zval]` treat it as the variadic argument. let default = defaults.remove(ident); let nullable = type_is_nullable(ty.as_ref())?; @@ -656,6 +695,7 @@ impl<'a> Args<'a> { default, as_ref, variadic, + php_type, }); } } @@ -746,6 +786,296 @@ impl<'a> Args<'a> { None => (&self.typed[..], &self.typed[0..0]), } } + + /// Parses `#[php(types = "...")]` or `#[php(union = "...")]` attribute from + /// parameter attributes. Returns the parsed PHP type declaration if found. + /// + /// Supports: + /// - `#[php(types = "int|string")]` - union of primitives + /// - `#[php(types = "Countable&Traversable")]` - intersection of classes + /// - `#[php(types = "Foo|Bar")]` - union of classes + /// - `#[php(union = "int|string")]` - backwards compatible union syntax + fn parse_type_attr(attrs: &[syn::Attribute]) -> Result> { + for attr in attrs { + if !attr.path().is_ident("php") { + continue; + } + + // Parse #[php(types = "...")] or #[php(union = "...")] + let nested = attr.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + )?; + + for meta in nested { + if let syn::Meta::NameValue(nv) = meta + && (nv.path.is_ident("types") || nv.path.is_ident("union")) + && let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = &nv.value + { + let type_str = lit_str.value(); + return Ok(Some(parse_php_type_string(&type_str)?)); + } + } + } + Ok(None) + } +} + +/// Converts a PHP type name string to a `DataType` token stream. +/// Returns `None` if the type name is not recognized. +fn php_type_name_to_data_type(type_name: &str) -> Option { + let tokens = match type_name.trim() { + "int" | "long" => quote! { ::ext_php_rs::flags::DataType::Long }, + "string" => quote! { ::ext_php_rs::flags::DataType::String }, + "bool" | "boolean" => quote! { ::ext_php_rs::flags::DataType::Bool }, + "float" | "double" => quote! { ::ext_php_rs::flags::DataType::Double }, + "array" => quote! { ::ext_php_rs::flags::DataType::Array }, + "null" => quote! { ::ext_php_rs::flags::DataType::Null }, + "object" => quote! { ::ext_php_rs::flags::DataType::Object(None) }, + "resource" => quote! { ::ext_php_rs::flags::DataType::Resource }, + "callable" => quote! { ::ext_php_rs::flags::DataType::Callable }, + "iterable" => quote! { ::ext_php_rs::flags::DataType::Iterable }, + "mixed" => quote! { ::ext_php_rs::flags::DataType::Mixed }, + "void" => quote! { ::ext_php_rs::flags::DataType::Void }, + "false" => quote! { ::ext_php_rs::flags::DataType::False }, + "true" => quote! { ::ext_php_rs::flags::DataType::True }, + "never" => quote! { ::ext_php_rs::flags::DataType::Never }, + _ => return None, + }; + Some(tokens) +} + +/// Parses a PHP type string and determines if it's a union, intersection, DNF, +/// or class union. +/// +/// Supports: +/// - `"int|string"` - union of primitives +/// - `"Countable&Traversable"` - intersection of classes/interfaces +/// - `"Foo|Bar"` - union of classes (when types start with uppercase) +/// - `"(A&B)|C"` - DNF (Disjunctive Normal Form) type (PHP 8.2+) +fn parse_php_type_string(type_str: &str) -> Result { + let type_str = type_str.trim(); + + // Check if it's a DNF type (contains parentheses with intersection) + if type_str.contains('(') && type_str.contains('&') { + return parse_dnf_type(type_str); + } + + // Check if it's an intersection type (contains & but no |) + if type_str.contains('&') { + if type_str.contains('|') { + // Has both & and | but no parentheses - invalid syntax + return Err(syn::Error::new( + Span::call_site(), + "DNF types require parentheses around intersection groups. Use '(A&B)|C' instead of 'A&B|C'.", + )); + } + + let class_names: Vec = type_str.split('&').map(|s| s.trim().to_string()).collect(); + + if class_names.len() < 2 { + return Err(syn::Error::new( + Span::call_site(), + "Intersection type must contain at least 2 types", + )); + } + + // Validate that all intersection members look like class names (start with + // uppercase) + for name in &class_names { + if name.is_empty() { + return Err(syn::Error::new( + Span::call_site(), + "Empty type name in intersection", + )); + } + if !name.chars().next().unwrap().is_uppercase() && name != "self" { + return Err(syn::Error::new( + Span::call_site(), + format!( + "Intersection types can only contain class/interface names. '{name}' looks like a primitive type.", + ), + )); + } + } + + return Ok(PhpTypeDecl::Intersection(class_names)); + } + + // It's a union type (contains |) + let parts: Vec<&str> = type_str.split('|').map(str::trim).collect(); + + if parts.len() < 2 { + return Err(syn::Error::new( + Span::call_site(), + "Type declaration must contain at least 2 types (e.g., 'int|string' or 'Foo&Bar')", + )); + } + + // Check if all parts are primitive types + let primitive_types: Vec> = parts + .iter() + .map(|p| php_type_name_to_data_type(p)) + .collect(); + + if primitive_types.iter().all(Option::is_some) { + // All are primitives - it's a primitive union + let tokens: Vec = primitive_types.into_iter().map(Option::unwrap).collect(); + return Ok(PhpTypeDecl::PrimitiveUnion(tokens)); + } + + // Check if all parts look like class names (start with uppercase or are 'null') + let all_classes = parts.iter().all(|p| { + let p = p.trim(); + p == "null" || p.chars().next().is_some_and(char::is_uppercase) || p == "self" + }); + + if all_classes { + // Filter out 'null' from class names - it's handled via allow_null + let class_names: Vec = parts + .iter() + .filter(|&&p| p != "null") + .map(|&p| p.to_string()) + .collect(); + + if class_names.is_empty() { + return Err(syn::Error::new( + Span::call_site(), + "Class union must contain at least one class name", + )); + } + + return Ok(PhpTypeDecl::ClassUnion(class_names)); + } + + // Mixed primitives and classes in union - treat unknown ones as class names + // Actually, for simplicity, if we have a mix, report an error + Err(syn::Error::new( + Span::call_site(), + format!( + "Cannot mix primitive types and class names in a union. \ + For primitive unions use: 'int|string|null'. \ + For class unions use: 'Foo|Bar'. Got: '{type_str}'", + ), + )) +} + +/// Parses a DNF (Disjunctive Normal Form) type string like "(A&B)|C" or +/// "(A&B)|(C&D)". +/// +/// Returns a `PhpTypeDecl::Dnf` with explicit `TypeGroup` variants: +/// - `TypeGroup::Single` for simple class names +/// - `TypeGroup::Intersection` for intersection groups +fn parse_dnf_type(type_str: &str) -> Result { + let mut groups: Vec = Vec::new(); + let mut current_pos = 0; + let chars: Vec = type_str.chars().collect(); + + while current_pos < chars.len() { + // Skip whitespace + while current_pos < chars.len() && chars[current_pos].is_whitespace() { + current_pos += 1; + } + + if current_pos >= chars.len() { + break; + } + + // Skip | separator + if chars[current_pos] == '|' { + current_pos += 1; + continue; + } + + if chars[current_pos] == '(' { + // Parse intersection group: (A&B&C) + current_pos += 1; // Skip '(' + let start = current_pos; + + // Find closing parenthesis + while current_pos < chars.len() && chars[current_pos] != ')' { + current_pos += 1; + } + + if current_pos >= chars.len() { + return Err(syn::Error::new( + Span::call_site(), + "Unclosed parenthesis in DNF type", + )); + } + + let group_str: String = chars[start..current_pos].iter().collect(); + current_pos += 1; // Skip ')' + + // Parse the intersection group + let class_names: Vec = group_str + .split('&') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + if class_names.len() < 2 { + return Err(syn::Error::new( + Span::call_site(), + "Intersection group in DNF type must contain at least 2 types", + )); + } + + // Validate class names + for name in &class_names { + if !name.chars().next().unwrap().is_uppercase() && name != "self" { + return Err(syn::Error::new( + Span::call_site(), + format!( + "Intersection types can only contain class/interface names. '{name}' looks like a primitive type.", + ), + )); + } + } + + groups.push(TypeGroup::Intersection(class_names)); + } else { + // Parse simple type name (until | or end) + let start = current_pos; + while current_pos < chars.len() + && chars[current_pos] != '|' + && !chars[current_pos].is_whitespace() + { + current_pos += 1; + } + + let type_name: String = chars[start..current_pos].iter().collect(); + let type_name = type_name.trim(); + + if !type_name.is_empty() { + // Validate it's a class name + if !type_name.chars().next().unwrap().is_uppercase() + && type_name != "self" + && type_name != "null" + { + return Err(syn::Error::new( + Span::call_site(), + format!( + "DNF types can only contain class/interface names. '{type_name}' looks like a primitive type.", + ), + )); + } + + groups.push(TypeGroup::Single(type_name.to_string())); + } + } + } + + if groups.len() < 2 { + return Err(syn::Error::new( + Span::call_site(), + "DNF type must contain at least 2 type groups", + )); + } + + Ok(PhpTypeDecl::Dnf(groups)) } impl TypedArg<'_> { @@ -802,6 +1132,74 @@ impl TypedArg<'_> { None }; let variadic = self.variadic.then(|| quote! { .is_variadic() }); + + // Check if we have a PHP type declaration override + if let Some(php_type) = &self.php_type { + return match php_type { + PhpTypeDecl::PrimitiveUnion(data_types) => { + let data_types = data_types.clone(); + quote! { + ::ext_php_rs::args::Arg::new_union(#name, vec![#(#data_types),*]) + #default + #as_ref + #variadic + } + } + PhpTypeDecl::Intersection(class_names) => { + quote! { + ::ext_php_rs::args::Arg::new_intersection( + #name, + vec![#(#class_names.to_string()),*] + ) + #default + #as_ref + #variadic + } + } + PhpTypeDecl::ClassUnion(class_names) => { + // Check if original type string included null for allow_null + quote! { + ::ext_php_rs::args::Arg::new_union_classes( + #name, + vec![#(#class_names.to_string()),*] + ) + #null + #default + #as_ref + #variadic + } + } + PhpTypeDecl::Dnf(groups) => { + // Generate TypeGroup variants for DNF type + let group_tokens: Vec<_> = groups + .iter() + .map(|group| match group { + TypeGroup::Single(name) => { + quote! { + ::ext_php_rs::args::TypeGroup::Single(#name.to_string()) + } + } + TypeGroup::Intersection(names) => { + quote! { + ::ext_php_rs::args::TypeGroup::Intersection(vec![#(#names.to_string()),*]) + } + } + }) + .collect(); + quote! { + ::ext_php_rs::args::Arg::new_dnf( + #name, + vec![#(#group_tokens),*] + ) + #null + #default + #as_ref + #variadic + } + } + }; + } + quote! { ::ext_php_rs::args::Arg::new(#name, <#ty as ::ext_php_rs::convert::FromZvalMut>::TYPE) #null @@ -999,7 +1397,8 @@ fn expr_to_php_stub(expr: &Expr) -> String { } } -/// Returns true if the given type is nullable in PHP (i.e., it's an `Option`). +/// Returns true if the given type is nullable in PHP (i.e., it's an +/// `Option`). /// /// Note: Having a default value does NOT make a type nullable. A parameter with /// a default value is optional (can be omitted), but passing `null` explicitly diff --git a/crates/macros/src/impl_.rs b/crates/macros/src/impl_.rs index 71d213735..1fe3c2924 100644 --- a/crates/macros/src/impl_.rs +++ b/crates/macros/src/impl_.rs @@ -54,8 +54,20 @@ pub fn parser(mut input: ItemImpl) -> Result { .unwrap_or(RenameRule::ScreamingSnake), ); parsed.parse(input.items.iter_mut())?; - let php_class_impl = parsed.generate_php_class_impl(); + + // Strip #[php(...)] attributes from method parameters before emitting output + // (must be done after generate_php_class_impl since parsed borrows from input) + for item in &mut input.items { + if let syn::ImplItem::Fn(method) = item { + for arg in &mut method.sig.inputs { + if let syn::FnArg::Typed(pat_type) = arg { + pat_type.attrs.retain(|attr| !attr.path().is_ident("php")); + } + } + } + } + Ok(quote::quote! { #input #php_class_impl diff --git a/crates/macros/src/interface.rs b/crates/macros/src/interface.rs index 756f40eec..bff79f47a 100644 --- a/crates/macros/src/interface.rs +++ b/crates/macros/src/interface.rs @@ -36,6 +36,17 @@ pub fn parser(mut input: ItemTrait) -> Result { let interface_data: InterfaceData = input.parse()?; let interface_tokens = quote! { #interface_data }; + // Strip #[php(...)] attributes from method parameters before emitting output + for item in &mut input.items { + if let TraitItem::Fn(method) = item { + for arg in &mut method.sig.inputs { + if let syn::FnArg::Typed(pat_type) = arg { + pat_type.attrs.retain(|attr| !attr.path().is_ident("php")); + } + } + } + } + Ok(quote! { #input diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index 87cde609f..c2a3b4645 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -651,6 +651,135 @@ fn php_interface_internal(_args: TokenStream2, input: TokenStream2) -> TokenStre /// # fn main() {} /// ``` /// +/// ## Union, Intersection, and DNF Types +/// +/// PHP 8.0+ supports union types (`int|string`), PHP 8.1+ supports intersection +/// types (`Countable&Traversable`), and PHP 8.2+ supports DNF (Disjunctive +/// Normal Form) types that combine both +/// (`(Countable&Traversable)|ArrayAccess`). +/// +/// You can declare these complex types using the `#[php(types = "...")]` +/// attribute on parameters. The parameter type should be `&Zval` since Rust +/// cannot directly represent these union/intersection types. +/// +/// > **PHP Version Requirements for Internal Functions:** +/// > +/// > - **Primitive union types** (`int|string|null`) work on all PHP 8.x +/// > versions +/// > - **Intersection types** (`Countable&Traversable`) require **PHP 8.3+** +/// > for +/// > reflection to show the correct type. On PHP 8.1-8.2, the type appears as +/// > `mixed` via reflection, but function calls still work correctly. +/// > - **Class union types** (`Foo|Bar`) require **PHP 8.3+** for full support +/// > - **DNF types** require **PHP 8.3+** for full support +/// > +/// > This is a PHP limitation where internal (C extension) functions did not +/// > fully +/// > support intersection/DNF types until PHP 8.3. See +/// > [php-src#11969](https://github.com/php/php-src/pull/11969) for details. +/// +/// ### Union Types (PHP 8.0+) +/// +/// ```rust,no_run,ignore +/// # #![cfg_attr(windows, feature(abi_vectorcall))] +/// # extern crate ext_php_rs; +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::types::Zval; +/// +/// /// Accepts int|string +/// #[php_function] +/// pub fn accept_int_or_string(#[php(types = "int|string")] value: &Zval) -> String { +/// if let Some(i) = value.long() { +/// format!("Got integer: {}", i) +/// } else if let Some(s) = value.str() { +/// format!("Got string: {}", s) +/// } else { +/// "Unknown type".to_string() +/// } +/// } +/// +/// /// Accepts float|bool|null +/// #[php_function] +/// pub fn accept_nullable(#[php(types = "float|bool|null")] value: &Zval) -> String { +/// "ok".to_string() +/// } +/// # fn main() {} +/// ``` +/// +/// ### Intersection Types (PHP 8.1+) +/// +/// Intersection types require a value to implement ALL specified interfaces: +/// +/// ```rust,no_run,ignore +/// # #![cfg_attr(windows, feature(abi_vectorcall))] +/// # extern crate ext_php_rs; +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::types::Zval; +/// +/// /// Accepts only objects implementing both Countable AND Traversable +/// #[php_function] +/// pub fn accept_countable_traversable( +/// #[php(types = "Countable&Traversable")] value: &Zval, +/// ) -> String { +/// "ok".to_string() +/// } +/// # fn main() {} +/// ``` +/// +/// ### DNF Types (PHP 8.2+) +/// +/// DNF (Disjunctive Normal Form) types combine union and intersection types. +/// Intersection groups must be wrapped in parentheses: +/// +/// ```rust,no_run,ignore +/// # #![cfg_attr(windows, feature(abi_vectorcall))] +/// # extern crate ext_php_rs; +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::types::Zval; +/// +/// /// Accepts (Countable&Traversable)|ArrayAccess +/// /// This accepts either: +/// /// - An object implementing both Countable AND Traversable, OR +/// /// - An object implementing ArrayAccess +/// #[php_function] +/// pub fn accept_dnf( +/// #[php(types = "(Countable&Traversable)|ArrayAccess")] value: &Zval, +/// ) -> String { +/// "ok".to_string() +/// } +/// +/// /// Multiple intersection groups: (Countable&Traversable)|(Iterator&ArrayAccess) +/// #[php_function] +/// pub fn accept_complex_dnf( +/// #[php(types = "(Countable&Traversable)|(Iterator&ArrayAccess)")] value: &Zval, +/// ) -> String { +/// "ok".to_string() +/// } +/// # fn main() {} +/// ``` +/// +/// ### Using the Builder API +/// +/// You can also create these types programmatically using the `FunctionBuilder` +/// API: +/// +/// ```rust,ignore +/// use ext_php_rs::args::{Arg, TypeGroup}; +/// use ext_php_rs::flags::DataType; +/// +/// // Union of primitives +/// Arg::new_union("value", vec![DataType::Long, DataType::String]); +/// +/// // Intersection type +/// Arg::new_intersection("value", vec!["Countable".to_string(), "Traversable".to_string()]); +/// +/// // DNF type: (Countable&Traversable)|ArrayAccess +/// Arg::new_dnf("value", vec![ +/// TypeGroup::Intersection(vec!["Countable".to_string(), "Traversable".to_string()]), +/// TypeGroup::Single("ArrayAccess".to_string()), +/// ]); +/// ``` +/// /// ## Returning `Result` /// /// You can also return a `Result` from the function. The error variant will be diff --git a/crates/macros/src/parsing.rs b/crates/macros/src/parsing.rs index 573a280db..1785ed7ad 100644 --- a/crates/macros/src/parsing.rs +++ b/crates/macros/src/parsing.rs @@ -163,8 +163,9 @@ impl PhpNameContext { /// Checks if a name is a PHP type keyword (case-insensitive). /// -/// Type keywords like `void`, `bool`, `int`, etc. are reserved for type declarations -/// but CAN be used as method, function, constant, or property names in PHP. +/// Type keywords like `void`, `bool`, `int`, etc. are reserved for type +/// declarations but CAN be used as method, function, constant, or property +/// names in PHP. fn is_php_type_keyword(name: &str) -> bool { let lower = name.to_lowercase(); PHP_TYPE_KEYWORDS @@ -183,13 +184,15 @@ pub fn is_php_reserved_keyword(name: &str) -> bool { /// Validates that a PHP name is not a reserved keyword. /// /// The validation is context-aware: -/// - For class, interface, enum, and enum case names: both reserved keywords AND type keywords are checked -/// - For method, function, constant, and property names: only reserved keywords are checked -/// (type keywords like `void`, `bool`, etc. are allowed) +/// - For class, interface, enum, and enum case names: both reserved keywords +/// AND type keywords are checked +/// - For method, function, constant, and property names: only reserved keywords +/// are checked (type keywords like `void`, `bool`, etc. are allowed) /// /// # Errors /// -/// Returns a `syn::Error` if the name is a reserved keyword in the given context. +/// Returns a `syn::Error` if the name is a reserved keyword in the given +/// context. pub fn validate_php_name( name: &str, context: PhpNameContext, diff --git a/docsrs_bindings.rs b/docsrs_bindings.rs index 7c096da27..bce010804 100644 --- a/docsrs_bindings.rs +++ b/docsrs_bindings.rs @@ -143,6 +143,9 @@ where pub const ZEND_DEBUG: u32 = 1; pub const _ZEND_TYPE_NAME_BIT: u32 = 16777216; pub const _ZEND_TYPE_LITERAL_NAME_BIT: u32 = 8388608; +pub const _ZEND_TYPE_LIST_BIT: u32 = 4194304; +pub const _ZEND_TYPE_INTERSECTION_BIT: u32 = 524288; +pub const _ZEND_TYPE_UNION_BIT: u32 = 262144; pub const _ZEND_TYPE_NULLABLE_BIT: u32 = 2; pub const HT_MIN_SIZE: u32 = 8; pub const IS_UNDEF: u32 = 0; @@ -409,6 +412,12 @@ pub struct zend_type { pub type_mask: u32, } #[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct zend_type_list { + pub num_types: u32, + pub types: [zend_type; 1usize], +} +#[repr(C)] #[derive(Copy, Clone)] pub union _zend_value { pub lval: zend_long, diff --git a/guide/src/macros/function.md b/guide/src/macros/function.md index fd636b00d..263f4537e 100644 --- a/guide/src/macros/function.md +++ b/guide/src/macros/function.md @@ -147,6 +147,130 @@ pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { # fn main() {} ``` +## Union, Intersection, and DNF Types + +PHP 8.0+ supports union types (`int|string`), PHP 8.1+ supports intersection +types (`Countable&Traversable`), and PHP 8.2+ supports DNF (Disjunctive Normal +Form) types that combine both (`(Countable&Traversable)|ArrayAccess`). + +You can declare these complex types using the `#[php(types = "...")]` attribute +on parameters. The parameter type should be `&Zval` since Rust cannot directly +represent these union/intersection types. + +> **PHP Version Requirements for Internal Functions:** +> +> - **Primitive union types** (`int|string|null`) work on all PHP 8.x versions +> - **Intersection types** (`Countable&Traversable`) require **PHP 8.3+** for +> reflection to show the correct type. On PHP 8.1-8.2, the type appears as +> `mixed` via reflection, but function calls still work correctly. +> - **Class union types** (`Foo|Bar`) require **PHP 8.3+** for full support +> - **DNF types** require **PHP 8.3+** for full support +> +> This is a PHP limitation where internal (C extension) functions did not fully +> support intersection/DNF types until PHP 8.3. See +> [php-src#11969](https://github.com/php/php-src/pull/11969) for details. + +### Union Types (PHP 8.0+) + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; + +/// Accepts int|string +#[php_function] +pub fn accept_int_or_string(#[php(types = "int|string")] value: &Zval) -> String { + if let Some(i) = value.long() { + format!("Got integer: {}", i) + } else if let Some(s) = value.str() { + format!("Got string: {}", s) + } else { + "Unknown type".to_string() + } +} + +/// Accepts float|bool|null +#[php_function] +pub fn accept_nullable(#[php(types = "float|bool|null")] value: &Zval) -> String { + "ok".to_string() +} +# fn main() {} +``` + +### Intersection Types (PHP 8.1+) + +Intersection types require a value to implement ALL specified interfaces: + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; + +/// Accepts only objects implementing both Countable AND Traversable +#[php_function] +pub fn accept_countable_traversable( + #[php(types = "Countable&Traversable")] value: &Zval, +) -> String { + "ok".to_string() +} +# fn main() {} +``` + +### DNF Types (PHP 8.2+) + +DNF (Disjunctive Normal Form) types combine union and intersection types. +Intersection groups must be wrapped in parentheses: + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; + +/// Accepts (Countable&Traversable)|ArrayAccess +/// This accepts either: +/// - An object implementing both Countable AND Traversable, OR +/// - An object implementing ArrayAccess +#[php_function] +pub fn accept_dnf( + #[php(types = "(Countable&Traversable)|ArrayAccess")] value: &Zval, +) -> String { + "ok".to_string() +} + +/// Multiple intersection groups: (Countable&Traversable)|(Iterator&ArrayAccess) +#[php_function] +pub fn accept_complex_dnf( + #[php(types = "(Countable&Traversable)|(Iterator&ArrayAccess)")] value: &Zval, +) -> String { + "ok".to_string() +} +# fn main() {} +``` + +### Using the Builder API + +You can also create these types programmatically using the `FunctionBuilder` API: + +```rust,ignore +use ext_php_rs::args::{Arg, TypeGroup}; +use ext_php_rs::flags::DataType; + +// Union of primitives +Arg::new_union("value", vec![DataType::Long, DataType::String]); + +// Intersection type +Arg::new_intersection("value", vec!["Countable".to_string(), "Traversable".to_string()]); + +// DNF type: (Countable&Traversable)|ArrayAccess +Arg::new_dnf("value", vec![ + TypeGroup::Intersection(vec!["Countable".to_string(), "Traversable".to_string()]), + TypeGroup::Single("ArrayAccess".to_string()), +]); +``` + ## Returning `Result` You can also return a `Result` from the function. The error variant will be diff --git a/guide/src/types/index.md b/guide/src/types/index.md index 05b2da1aa..ff05bf69a 100644 --- a/guide/src/types/index.md +++ b/guide/src/types/index.md @@ -34,3 +34,16 @@ Return types can also include: For a type to be returnable, it must implement `IntoZval`, while for it to be valid as a parameter, it must implement `FromZval`. + +## Complex Type Declarations + +For parameters that need PHP's advanced type system features (union types, +intersection types, or DNF types), you can use `&Zval` as the parameter type +with the `#[php(types = "...")]` attribute: + +- **Union types** (PHP 8.0+): `#[php(types = "int|string")]` +- **Intersection types** (PHP 8.1+): `#[php(types = "Countable&Traversable")]` +- **DNF types** (PHP 8.2+): `#[php(types = "(Countable&Traversable)|ArrayAccess")]` + +See the [function macro documentation](../macros/function.md#union-intersection-and-dnf-types) +for detailed examples. diff --git a/src/args.rs b/src/args.rs index c110b3b79..d6a21efe9 100644 --- a/src/args.rs +++ b/src/args.rs @@ -18,12 +18,84 @@ use crate::{ zend::ZendType, }; +/// Represents a single element in a DNF type - either a simple class or an intersection group. +#[derive(Debug, Clone, PartialEq)] +pub enum TypeGroup { + /// A single class/interface type: `ArrayAccess` + Single(String), + /// An intersection of class/interface types: `Countable&Traversable` + Intersection(Vec), +} + +/// Represents the PHP type(s) for an argument. +#[derive(Debug, Clone, PartialEq)] +pub enum ArgType { + /// A single type (e.g., `int`, `string`, `MyClass`) + Single(DataType), + /// A union of primitive types (e.g., `int|string|null`) + /// Note: For unions containing class types, use `UnionClasses`. + Union(Vec), + /// An intersection of class/interface types (e.g., `Countable&Traversable`) + /// Only available in PHP 8.1+. + Intersection(Vec), + /// A union of class/interface types (e.g., `Foo|Bar`) + UnionClasses(Vec), + /// A DNF (Disjunctive Normal Form) type (e.g., `(Countable&Traversable)|ArrayAccess`) + /// Only available in PHP 8.2+. + Dnf(Vec), +} + +impl PartialEq for ArgType { + fn eq(&self, other: &DataType) -> bool { + match self { + ArgType::Single(dt) => dt == other, + ArgType::Union(_) + | ArgType::Intersection(_) + | ArgType::UnionClasses(_) + | ArgType::Dnf(_) => false, + } + } +} + +impl From for ArgType { + fn from(dt: DataType) -> Self { + ArgType::Single(dt) + } +} + +impl ArgType { + /// Returns the primary [`DataType`] for this argument type. + /// For complex types, returns Mixed as a fallback for runtime type checking. + #[must_use] + pub fn primary_type(&self) -> DataType { + match self { + ArgType::Single(dt) => *dt, + ArgType::Union(_) + | ArgType::Intersection(_) + | ArgType::UnionClasses(_) + | ArgType::Dnf(_) => DataType::Mixed, + } + } + + /// Returns true if this type allows null values. + #[must_use] + pub fn allows_null(&self) -> bool { + match self { + ArgType::Single(dt) => matches!(dt, DataType::Null), + ArgType::Union(types) => types.iter().any(|t| matches!(t, DataType::Null)), + // Intersection, class union, and DNF types cannot directly include null + // (use allow_null() for nullable variants) + ArgType::Intersection(_) | ArgType::UnionClasses(_) | ArgType::Dnf(_) => false, + } + } +} + /// Represents an argument to a function. #[must_use] #[derive(Debug)] pub struct Arg<'a> { name: String, - r#type: DataType, + r#type: ArgType, as_ref: bool, allow_null: bool, pub(crate) variadic: bool, @@ -42,7 +114,148 @@ impl<'a> Arg<'a> { pub fn new>(name: T, r#type: DataType) -> Self { Arg { name: name.into(), - r#type, + r#type: ArgType::Single(r#type), + as_ref: false, + allow_null: false, + variadic: false, + default_value: None, + zval: None, + variadic_zvals: vec![], + } + } + + /// Creates a new argument with a union type. + /// + /// This creates a PHP union type (e.g., `int|string`) for the argument. + /// Only primitive types are currently supported in unions. + /// + /// # Parameters + /// + /// * `name` - The name of the parameter. + /// * `types` - The types to include in the union. + /// + /// # Example + /// + /// ```ignore + /// use ext_php_rs::args::Arg; + /// use ext_php_rs::flags::DataType; + /// + /// // Creates an argument with type `int|string` + /// let arg = Arg::new_union("value", vec![DataType::Long, DataType::String]); + /// + /// // Creates an argument with type `int|string|null` + /// let nullable_arg = Arg::new_union("value", vec![ + /// DataType::Long, + /// DataType::String, + /// DataType::Null, + /// ]); + /// ``` + pub fn new_union>(name: T, types: Vec) -> Self { + Arg { + name: name.into(), + r#type: ArgType::Union(types), + as_ref: false, + allow_null: false, + variadic: false, + default_value: None, + zval: None, + variadic_zvals: vec![], + } + } + + /// Creates a new argument with an intersection type (PHP 8.1+). + /// + /// This creates a PHP intersection type (e.g., `Countable&Traversable`) for + /// the argument. The value must implement ALL of the specified interfaces. + /// + /// # Parameters + /// + /// * `name` - The name of the parameter. + /// * `class_names` - The class/interface names that form the intersection. + /// + /// # Example + /// + /// ```ignore + /// use ext_php_rs::args::Arg; + /// + /// // Creates an argument with type `Countable&Traversable` + /// let arg = Arg::new_intersection("value", vec![ + /// "Countable".to_string(), + /// "Traversable".to_string(), + /// ]); + /// ``` + pub fn new_intersection>(name: T, class_names: Vec) -> Self { + Arg { + name: name.into(), + r#type: ArgType::Intersection(class_names), + as_ref: false, + allow_null: false, + variadic: false, + default_value: None, + zval: None, + variadic_zvals: vec![], + } + } + + /// Creates a new argument with a union of class types (PHP 8.0+). + /// + /// This creates a PHP union type where each element is a class/interface + /// (e.g., `Foo|Bar`). For primitive type unions, use [`Self::new_union`]. + /// + /// # Parameters + /// + /// * `name` - The name of the parameter. + /// * `class_names` - The class/interface names that form the union. + /// + /// # Example + /// + /// ```ignore + /// use ext_php_rs::args::Arg; + /// + /// // Creates an argument with type `Iterator|IteratorAggregate` + /// let arg = Arg::new_union_classes("value", vec![ + /// "Iterator".to_string(), + /// "IteratorAggregate".to_string(), + /// ]); + /// ``` + pub fn new_union_classes>(name: T, class_names: Vec) -> Self { + Arg { + name: name.into(), + r#type: ArgType::UnionClasses(class_names), + as_ref: false, + allow_null: false, + variadic: false, + default_value: None, + zval: None, + variadic_zvals: vec![], + } + } + + /// Creates a new argument with a DNF (Disjunctive Normal Form) type (PHP 8.2+). + /// + /// DNF types allow combining intersection and union types, such as + /// `(Countable&Traversable)|ArrayAccess`. + /// + /// # Parameters + /// + /// * `name` - The name of the parameter. + /// * `groups` - Type groups using explicit `TypeGroup` variants. + /// + /// # Example + /// + /// ```ignore + /// use ext_php_rs::args::{Arg, TypeGroup}; + /// + /// // Creates an argument with type `(Countable&Traversable)|ArrayAccess` + /// let arg = Arg::new_dnf("value", vec![ + /// TypeGroup::Intersection(vec!["Countable".to_string(), "Traversable".to_string()]), + /// TypeGroup::Single("ArrayAccess".to_string()), + /// ]); + /// ``` + pub fn new_dnf>(name: T, groups: Vec) -> Self { + Arg { + name: name.into(), + r#type: ArgType::Dnf(groups), as_ref: false, allow_null: false, variadic: false, @@ -157,16 +370,91 @@ impl<'a> Arg<'a> { } /// Returns the internal PHP argument info. + /// + /// Note: Intersection, class union, and DNF types for internal function parameters + /// are only supported in PHP 8.3+. On earlier versions, these fall back to `mixed` type. + /// See: pub(crate) fn as_arg_info(&self) -> Result { + let type_ = match &self.r#type { + ArgType::Single(dt) => { + ZendType::empty_from_type(*dt, self.as_ref, self.variadic, self.allow_null) + .ok_or(Error::InvalidCString)? + } + ArgType::Union(types) => { + // Primitive union types (int|string|null etc.) work on all PHP 8.x versions + ZendType::union_primitive(types, self.as_ref, self.variadic) + } + #[cfg(php83)] + ArgType::Intersection(class_names) => { + // Intersection types for internal functions require PHP 8.3+ + let names: Vec<&str> = class_names.iter().map(String::as_str).collect(); + ZendType::intersection(&names, self.as_ref, self.variadic) + .ok_or(Error::InvalidCString)? + } + #[cfg(not(php83))] + ArgType::Intersection(_) => { + // PHP < 8.3 doesn't support intersection types for internal functions. + // Fall back to mixed type with allow_null handling. + ZendType::empty_from_type( + DataType::Mixed, + self.as_ref, + self.variadic, + self.allow_null, + ) + .ok_or(Error::InvalidCString)? + } + #[cfg(php83)] + ArgType::UnionClasses(class_names) => { + // Class union types for internal functions require PHP 8.3+ + let names: Vec<&str> = class_names.iter().map(String::as_str).collect(); + ZendType::union_classes(&names, self.as_ref, self.variadic, self.allow_null) + .ok_or(Error::InvalidCString)? + } + #[cfg(not(php83))] + ArgType::UnionClasses(_) => { + // PHP < 8.3 doesn't support class union types for internal functions. + // Fall back to mixed type. + ZendType::empty_from_type( + DataType::Mixed, + self.as_ref, + self.variadic, + self.allow_null, + ) + .ok_or(Error::InvalidCString)? + } + #[cfg(php83)] + ArgType::Dnf(groups) => { + // DNF types for internal functions require PHP 8.3+ + let groups: Vec> = groups + .iter() + .map(|g| match g { + TypeGroup::Single(name) => vec![name.as_str()], + TypeGroup::Intersection(names) => { + names.iter().map(String::as_str).collect() + } + }) + .collect(); + let groups_refs: Vec<&[&str]> = groups.iter().map(Vec::as_slice).collect(); + ZendType::dnf(&groups_refs, self.as_ref, self.variadic) + .ok_or(Error::InvalidCString)? + } + #[cfg(not(php83))] + ArgType::Dnf(_) => { + // PHP < 8.3 doesn't support DNF types for internal functions. + // Fall back to mixed type. + ZendType::empty_from_type( + DataType::Mixed, + self.as_ref, + self.variadic, + self.allow_null, + ) + .ok_or(Error::InvalidCString)? + } + }; + Ok(ArgInfo { name: CString::new(self.name.as_str())?.into_raw(), - type_: ZendType::empty_from_type( - self.r#type, - self.as_ref, - self.variadic, - self.allow_null, - ) - .ok_or(Error::InvalidCString)?, + type_, default_value: match &self.default_value { Some(val) if val.as_str() == "None" => CString::new("null")?.into_raw(), Some(val) => CString::new(val.as_str())?.into_raw(), @@ -178,15 +466,17 @@ impl<'a> Arg<'a> { impl From> for _zend_expected_type { fn from(arg: Arg) -> Self { - let type_id = match arg.r#type { + // For union types, we use the primary type for expected type errors + let dt = arg.r#type.primary_type(); + let type_id = match dt { DataType::False | DataType::True => _zend_expected_type_Z_EXPECTED_BOOL, DataType::Long => _zend_expected_type_Z_EXPECTED_LONG, DataType::Double => _zend_expected_type_Z_EXPECTED_DOUBLE, DataType::String => _zend_expected_type_Z_EXPECTED_STRING, DataType::Array => _zend_expected_type_Z_EXPECTED_ARRAY, - DataType::Object(_) => _zend_expected_type_Z_EXPECTED_OBJECT, DataType::Resource => _zend_expected_type_Z_EXPECTED_RESOURCE, - _ => unreachable!(), + // Object, Mixed (used by unions), and other types use OBJECT as a fallback + _ => _zend_expected_type_Z_EXPECTED_OBJECT, }; if arg.allow_null { type_id + 1 } else { type_id } @@ -195,10 +485,20 @@ impl From> for _zend_expected_type { impl From> for Parameter { fn from(val: Arg<'_>) -> Self { + // For Parameter (used in describe), use the primary type + // TODO: Extend Parameter to support union/intersection/DNF types for better stub generation + let ty = match &val.r#type { + ArgType::Single(dt) => Some(*dt), + // For complex types, fall back to Mixed (Object would be more accurate for class types) + ArgType::Union(_) + | ArgType::Intersection(_) + | ArgType::UnionClasses(_) + | ArgType::Dnf(_) => Some(DataType::Mixed), + }; Parameter { name: val.name.into(), - ty: Some(val.r#type).into(), - nullable: val.allow_null, + ty: ty.into(), + nullable: val.allow_null || val.r#type.allows_null(), variadic: val.variadic, default: val.default_value.map(abi::RString::from).into(), } @@ -567,5 +867,56 @@ mod tests { assert_eq!(parser.args[0].r#type, DataType::Long); } + #[test] + fn test_new_union() { + let arg = Arg::new_union("test", vec![DataType::Long, DataType::String]); + assert_eq!(arg.name, "test"); + assert!(matches!(arg.r#type, ArgType::Union(_))); + if let ArgType::Union(types) = &arg.r#type { + assert_eq!(types.len(), 2); + assert!(types.contains(&DataType::Long)); + assert!(types.contains(&DataType::String)); + } + assert!(!arg.as_ref); + assert!(!arg.allow_null); + assert!(!arg.variadic); + } + + #[test] + fn test_union_with_null() { + let arg = Arg::new_union( + "nullable", + vec![DataType::Long, DataType::String, DataType::Null], + ); + assert!(arg.r#type.allows_null()); + } + + #[test] + fn test_union_without_null() { + let arg = Arg::new_union("non_nullable", vec![DataType::Long, DataType::String]); + assert!(!arg.r#type.allows_null()); + } + + #[test] + fn test_argtype_primary_type() { + let single = ArgType::Single(DataType::Long); + assert_eq!(single.primary_type(), DataType::Long); + + let union = ArgType::Union(vec![DataType::Long, DataType::String]); + assert_eq!(union.primary_type(), DataType::Mixed); + } + + #[test] + fn test_argtype_eq_datatype() { + let single = ArgType::Single(DataType::Long); + assert_eq!(single, DataType::Long); + assert_ne!(single, DataType::String); + + let union = ArgType::Union(vec![DataType::Long, DataType::String]); + // Union should not equal any single DataType + assert_ne!(union, DataType::Long); + assert_ne!(union, DataType::Mixed); + } + // TODO: test parse } diff --git a/src/ffi.rs b/src/ffi.rs index 87103b72c..e317e9d32 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -44,6 +44,8 @@ unsafe extern "C" { ) -> bool; pub fn ext_php_rs_zend_bailout() -> !; + + pub fn ext_php_rs_pemalloc(size: usize) -> *mut c_void; } include!(concat!(env!("OUT_DIR"), "/bindings.rs")); diff --git a/src/wrapper.c b/src/wrapper.c index 745262afb..3205daf1f 100644 --- a/src/wrapper.c +++ b/src/wrapper.c @@ -111,3 +111,7 @@ bool ext_php_rs_zend_first_try_catch(void* (*callback)(void *), void *ctx, void void ext_php_rs_zend_bailout() { zend_bailout(); } + +void *ext_php_rs_pemalloc(size_t size) { + return pemalloc(size, 1); +} diff --git a/src/wrapper.h b/src/wrapper.h index 88c86e427..db87e8b96 100644 --- a/src/wrapper.h +++ b/src/wrapper.h @@ -56,3 +56,4 @@ sapi_module_struct *ext_php_rs_sapi_module(); bool ext_php_rs_zend_try_catch(void* (*callback)(void *), void *ctx, void **result); bool ext_php_rs_zend_first_try_catch(void* (*callback)(void *), void *ctx, void **result); void ext_php_rs_zend_bailout(); +void *ext_php_rs_pemalloc(size_t size); diff --git a/src/zend/_type.rs b/src/zend/_type.rs index 9dfa5c686..264c7e63f 100644 --- a/src/zend/_type.rs +++ b/src/zend/_type.rs @@ -2,8 +2,9 @@ use std::{ffi::c_void, ptr}; use crate::{ ffi::{ - _IS_BOOL, _ZEND_IS_VARIADIC_BIT, _ZEND_SEND_MODE_SHIFT, _ZEND_TYPE_NULLABLE_BIT, IS_MIXED, - MAY_BE_ANY, MAY_BE_BOOL, zend_type, + _IS_BOOL, _ZEND_IS_VARIADIC_BIT, _ZEND_SEND_MODE_SHIFT, _ZEND_TYPE_INTERSECTION_BIT, + _ZEND_TYPE_LIST_BIT, _ZEND_TYPE_NULLABLE_BIT, _ZEND_TYPE_UNION_BIT, IS_MIXED, MAY_BE_ANY, + MAY_BE_BOOL, ext_php_rs_pemalloc, zend_type, zend_type_list, }, flags::DataType, }; @@ -173,4 +174,492 @@ impl ZendType { 0 }) | Self::arg_info_flags(pass_by_ref, is_variadic) } + + /// Converts a [`DataType`] to its `MAY_BE_*` mask value. + /// + /// This is used for building union types where multiple types are + /// combined with bitwise OR in the `type_mask`. + #[must_use] + pub fn type_to_mask(type_: DataType) -> u32 { + let type_val = type_.as_u32(); + if type_val == _IS_BOOL { + MAY_BE_BOOL + } else if type_val == IS_MIXED { + MAY_BE_ANY + } else { + 1 << type_val + } + } + + /// Creates a union type from multiple primitive data types. + /// + /// This method creates a PHP union type (e.g., `int|string|null`) by + /// combining the type masks of multiple primitive types. This only + /// supports primitive types; unions containing class types are not + /// yet supported by this method. + /// + /// # Parameters + /// + /// * `types` - Slice of primitive data types to include in the union. + /// * `pass_by_ref` - Whether the value should be passed by reference. + /// * `is_variadic` - Whether this type represents a variadic argument. + /// + /// # Panics + /// + /// Panics if any of the types is a class object type with a class name. + /// + /// # Example + /// + /// ```ignore + /// use ext_php_rs::flags::DataType; + /// use ext_php_rs::zend::ZendType; + /// + /// // Creates `int|string` union type + /// let union_type = ZendType::union_primitive( + /// &[DataType::Long, DataType::String], + /// false, + /// false, + /// ); + /// + /// // Creates `int|string|null` union type + /// let nullable_union = ZendType::union_primitive( + /// &[DataType::Long, DataType::String, DataType::Null], + /// false, + /// false, + /// ); + /// ``` + #[must_use] + pub fn union_primitive(types: &[DataType], pass_by_ref: bool, is_variadic: bool) -> Self { + let mut type_mask = Self::arg_info_flags(pass_by_ref, is_variadic); + + for type_ in types { + assert!( + !matches!(type_, DataType::Object(Some(_))), + "union_primitive does not support class types" + ); + type_mask |= Self::type_to_mask(*type_); + } + + Self { + ptr: ptr::null_mut::(), + type_mask, + } + } + + /// Checks if null is included in this type's mask. + #[must_use] + pub fn allows_null(&self) -> bool { + // Null is allowed if either the nullable bit is set OR if the type mask includes MAY_BE_NULL + (self.type_mask & _ZEND_TYPE_NULLABLE_BIT) != 0 + || (self.type_mask & (1 << crate::ffi::IS_NULL)) != 0 + } + + /// Creates an intersection type from multiple class/interface names (PHP 8.1+). + /// + /// Intersection types represent a value that must satisfy ALL of the given + /// type constraints simultaneously (e.g., `Countable&Traversable`). + /// + /// # Parameters + /// + /// * `class_names` - Slice of class/interface names that form the intersection. + /// * `pass_by_ref` - Whether the value should be passed by reference. + /// * `is_variadic` - Whether this type represents a variadic argument. + /// + /// # Returns + /// + /// Returns `None` if any class name contains NUL bytes. + /// + /// # Panics + /// + /// Panics if fewer than 2 class names are provided. + /// + /// # Example + /// + /// ```ignore + /// use ext_php_rs::zend::ZendType; + /// + /// // Creates `Countable&Traversable` intersection type + /// let intersection = ZendType::intersection( + /// &["Countable", "Traversable"], + /// false, + /// false, + /// ).unwrap(); + /// ``` + #[must_use] + pub fn intersection( + class_names: &[&str], + pass_by_ref: bool, + is_variadic: bool, + ) -> Option { + assert!( + class_names.len() >= 2, + "Intersection types require at least 2 types" + ); + + // Allocate the type list structure with space for all types + // The zend_type_list has a flexible array member, so we need to + // allocate extra space for the additional types. + // We use PHP's __zend_malloc for persistent allocation so PHP can + // properly free this memory during shutdown. + let list_size = std::mem::size_of::() + + (class_names.len() - 1) * std::mem::size_of::(); + + // SAFETY: ext_php_rs_pemalloc returns properly aligned memory for any type. + // The cast is safe because zend_type_list only requires pointer alignment. + #[allow(clippy::cast_ptr_alignment)] + let list_ptr = unsafe { ext_php_rs_pemalloc(list_size).cast::() }; + if list_ptr.is_null() { + return None; + } + + // Zero-initialize the entire allocated memory (including extra type entries) + // This is important for PHP versions that may iterate over uninitialized padding bytes + unsafe { + std::ptr::write_bytes(list_ptr.cast::(), 0, list_size); + } + + // SAFETY: list_ptr is valid and properly aligned + unsafe { + #[allow(clippy::cast_possible_truncation)] + { + (*list_ptr).num_types = class_names.len() as u32; + } + + // Get a pointer to the types array + let types_ptr = (*list_ptr).types.as_mut_ptr(); + + for (i, class_name) in class_names.iter().enumerate() { + let type_entry = types_ptr.add(i); + + // PHP 8.3+ uses zend_string* with _ZEND_TYPE_NAME_BIT for type list entries + // PHP < 8.3 uses const char* with _ZEND_TYPE_NAME_BIT + cfg_if::cfg_if! { + if #[cfg(php83)] { + let zend_str = crate::ffi::ext_php_rs_zend_string_init( + class_name.as_ptr().cast(), + class_name.len(), + true, // persistent allocation + ); + if zend_str.is_null() { + return None; + } + (*type_entry).ptr = zend_str.cast::(); + (*type_entry).type_mask = crate::ffi::_ZEND_TYPE_NAME_BIT; + } else { + let class_cstr = match std::ffi::CString::new(*class_name) { + Ok(s) => s, + Err(_) => return None, + }; + (*type_entry).ptr = class_cstr.into_raw().cast::(); + (*type_entry).type_mask = crate::ffi::_ZEND_TYPE_NAME_BIT; + } + } + } + } + + // Build the final type mask with intersection and list bits + let type_mask = Self::arg_info_flags(pass_by_ref, is_variadic) + | _ZEND_TYPE_LIST_BIT + | _ZEND_TYPE_INTERSECTION_BIT; + + Some(Self { + ptr: list_ptr.cast::(), + type_mask, + }) + } + + /// Creates a union type containing class types (PHP 8.0+). + /// + /// This method creates a PHP union type where each element is a class/interface + /// type (e.g., `Foo|Bar`). For primitive type unions, use [`Self::union_primitive`]. + /// + /// # Parameters + /// + /// * `class_names` - Slice of class/interface names that form the union. + /// * `pass_by_ref` - Whether the value should be passed by reference. + /// * `is_variadic` - Whether this type represents a variadic argument. + /// * `allow_null` - Whether null should be allowed in the union. + /// + /// # Returns + /// + /// Returns `None` if any class name contains NUL bytes. + /// + /// # Panics + /// + /// Panics if fewer than 2 class names are provided (unless `allow_null` is true, + /// in which case 1 is acceptable). + #[must_use] + pub fn union_classes( + class_names: &[&str], + pass_by_ref: bool, + is_variadic: bool, + allow_null: bool, + ) -> Option { + let min_types = if allow_null { 1 } else { 2 }; + assert!( + class_names.len() >= min_types, + "Union types require at least {min_types} types" + ); + + // Allocate the type list structure using PHP's allocator + // so PHP can properly free this memory during shutdown. + let list_size = std::mem::size_of::() + + (class_names.len() - 1) * std::mem::size_of::(); + + // SAFETY: ext_php_rs_pemalloc returns properly aligned memory for any type. + #[allow(clippy::cast_ptr_alignment)] + let list_ptr = unsafe { ext_php_rs_pemalloc(list_size).cast::() }; + if list_ptr.is_null() { + return None; + } + + // Zero-initialize the entire allocated memory (including extra type entries) + // This is important for PHP versions that may iterate over uninitialized padding bytes + unsafe { + std::ptr::write_bytes(list_ptr.cast::(), 0, list_size); + } + + unsafe { + #[allow(clippy::cast_possible_truncation)] + { + (*list_ptr).num_types = class_names.len() as u32; + } + let types_ptr = (*list_ptr).types.as_mut_ptr(); + + for (i, class_name) in class_names.iter().enumerate() { + let type_entry = types_ptr.add(i); + + // PHP 8.3+ uses zend_string* with _ZEND_TYPE_NAME_BIT for type list entries + // PHP < 8.3 uses const char* with _ZEND_TYPE_NAME_BIT + cfg_if::cfg_if! { + if #[cfg(php83)] { + let zend_str = crate::ffi::ext_php_rs_zend_string_init( + class_name.as_ptr().cast(), + class_name.len(), + true, // persistent allocation + ); + if zend_str.is_null() { + return None; + } + (*type_entry).ptr = zend_str.cast::(); + (*type_entry).type_mask = crate::ffi::_ZEND_TYPE_NAME_BIT; + } else { + let class_cstr = match std::ffi::CString::new(*class_name) { + Ok(s) => s, + Err(_) => return None, + }; + (*type_entry).ptr = class_cstr.into_raw().cast::(); + (*type_entry).type_mask = crate::ffi::_ZEND_TYPE_NAME_BIT; + } + } + } + } + + let mut type_mask = Self::arg_info_flags(pass_by_ref, is_variadic) + | _ZEND_TYPE_LIST_BIT + | _ZEND_TYPE_UNION_BIT; + + if allow_null { + type_mask |= _ZEND_TYPE_NULLABLE_BIT; + } + + Some(Self { + ptr: list_ptr.cast::(), + type_mask, + }) + } + + /// Checks if this type is an intersection type. + #[must_use] + pub fn is_intersection(&self) -> bool { + (self.type_mask & _ZEND_TYPE_INTERSECTION_BIT) != 0 + } + + /// Checks if this type contains a type list (union or intersection with classes). + #[must_use] + pub fn has_type_list(&self) -> bool { + (self.type_mask & _ZEND_TYPE_LIST_BIT) != 0 + } + + /// Checks if this type is a union type (excluding primitive-only unions). + #[must_use] + pub fn is_union(&self) -> bool { + (self.type_mask & _ZEND_TYPE_UNION_BIT) != 0 + } + + /// Creates a DNF (Disjunctive Normal Form) type (PHP 8.2+). + /// + /// DNF types are unions where each element can be either a simple class/interface + /// or an intersection group. For example: `(Countable&Traversable)|ArrayAccess` + /// + /// # Parameters + /// + /// * `groups` - Slice of type groups. Each inner slice represents either: + /// - A single class name (simple type) + /// - Multiple class names (intersection group) + /// * `pass_by_ref` - Whether the value should be passed by reference. + /// * `is_variadic` - Whether this type represents a variadic argument. + /// + /// # Returns + /// + /// Returns `None` if any class name contains NUL bytes or allocation fails. + /// + /// # Panics + /// + /// Panics if fewer than 2 groups are provided, or if any intersection group + /// has fewer than 2 types. + /// + /// # Example + /// + /// ```ignore + /// use ext_php_rs::zend::ZendType; + /// + /// // Creates `(Countable&Traversable)|ArrayAccess` DNF type + /// let dnf = ZendType::dnf( + /// &[&["Countable", "Traversable"], &["ArrayAccess"]], + /// false, + /// false, + /// ).unwrap(); + /// ``` + #[must_use] + #[allow(clippy::too_many_lines)] + pub fn dnf(groups: &[&[&str]], pass_by_ref: bool, is_variadic: bool) -> Option { + assert!( + groups.len() >= 2, + "DNF types require at least 2 type groups" + ); + + // Validate: intersection groups must have at least 2 types + for group in groups { + if group.len() >= 2 { + // This is an intersection group, which is valid + } else if group.len() == 1 { + // Single type is valid + } else { + panic!("Empty type group in DNF type"); + } + } + + // Allocate the outer type list using PHP's allocator + // so PHP can properly free this memory during shutdown. + let outer_list_size = std::mem::size_of::() + + (groups.len() - 1) * std::mem::size_of::(); + + #[allow(clippy::cast_ptr_alignment)] + let outer_list_ptr = + unsafe { ext_php_rs_pemalloc(outer_list_size).cast::() }; + if outer_list_ptr.is_null() { + return None; + } + + // Zero-initialize the entire allocated memory (including extra type entries) + // This is important for PHP versions that may iterate over uninitialized padding bytes + unsafe { + std::ptr::write_bytes(outer_list_ptr.cast::(), 0, outer_list_size); + } + + unsafe { + #[allow(clippy::cast_possible_truncation)] + { + (*outer_list_ptr).num_types = groups.len() as u32; + } + + let outer_types_ptr = (*outer_list_ptr).types.as_mut_ptr(); + + for (i, group) in groups.iter().enumerate() { + let type_entry = outer_types_ptr.add(i); + + if group.len() == 1 { + // Simple class type + let class_name = group[0]; + + // PHP 8.3+ uses zend_string* with _ZEND_TYPE_NAME_BIT for type list entries + // PHP < 8.3 uses const char* with _ZEND_TYPE_NAME_BIT + cfg_if::cfg_if! { + if #[cfg(php83)] { + let zend_str = crate::ffi::ext_php_rs_zend_string_init( + class_name.as_ptr().cast(), + class_name.len(), + true, + ); + if zend_str.is_null() { + return None; + } + (*type_entry).ptr = zend_str.cast::(); + (*type_entry).type_mask = crate::ffi::_ZEND_TYPE_NAME_BIT; + } else { + let class_cstr = match std::ffi::CString::new(class_name) { + Ok(s) => s, + Err(_) => return None, + }; + (*type_entry).ptr = class_cstr.into_raw().cast::(); + (*type_entry).type_mask = crate::ffi::_ZEND_TYPE_NAME_BIT; + } + } + } else { + // Intersection group - need to create a nested type list + // Use PHP's allocator so PHP can properly free this memory. + let inner_list_size = std::mem::size_of::() + + (group.len() - 1) * std::mem::size_of::(); + + #[allow(clippy::cast_ptr_alignment)] + let inner_list_ptr = + ext_php_rs_pemalloc(inner_list_size).cast::(); + if inner_list_ptr.is_null() { + return None; + } + + // Zero-initialize the entire allocated memory (including extra type entries) + std::ptr::write_bytes(inner_list_ptr.cast::(), 0, inner_list_size); + + #[allow(clippy::cast_possible_truncation)] + { + (*inner_list_ptr).num_types = group.len() as u32; + } + + let inner_types_ptr = (*inner_list_ptr).types.as_mut_ptr(); + + for (j, class_name) in group.iter().enumerate() { + let inner_type_entry = inner_types_ptr.add(j); + + cfg_if::cfg_if! { + if #[cfg(php83)] { + let zend_str = crate::ffi::ext_php_rs_zend_string_init( + class_name.as_ptr().cast(), + class_name.len(), + true, + ); + if zend_str.is_null() { + return None; + } + (*inner_type_entry).ptr = zend_str.cast::(); + (*inner_type_entry).type_mask = crate::ffi::_ZEND_TYPE_NAME_BIT; + } else { + let class_cstr = match std::ffi::CString::new(*class_name) { + Ok(s) => s, + Err(_) => return None, + }; + (*inner_type_entry).ptr = class_cstr.into_raw().cast::(); + (*inner_type_entry).type_mask = crate::ffi::_ZEND_TYPE_NAME_BIT; + } + } + } + + // Set up the outer type entry to point to the intersection list + (*type_entry).ptr = inner_list_ptr.cast::(); + (*type_entry).type_mask = _ZEND_TYPE_LIST_BIT | _ZEND_TYPE_INTERSECTION_BIT; + } + } + } + + // Build the final type mask with union and list bits + let type_mask = Self::arg_info_flags(pass_by_ref, is_variadic) + | _ZEND_TYPE_LIST_BIT + | _ZEND_TYPE_UNION_BIT; + + Some(Self { + ptr: outer_list_ptr.cast::(), + type_mask, + }) + } } diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 3e70eb950..d88bf9642 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -18,3 +18,6 @@ static = ["ext-php-rs/static"] [lib] crate-type = ["cdylib"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(php82)', 'cfg(php84)'] } diff --git a/tests/build.rs b/tests/build.rs new file mode 100644 index 000000000..a0b678542 --- /dev/null +++ b/tests/build.rs @@ -0,0 +1,88 @@ +//! Build script for the tests crate that detects PHP version and sets cfg flags. +//! +//! This mirrors the PHP version detection in ext-php-rs's build.rs to ensure +//! conditional compilation flags like `php82` are set correctly for the test code. + +use std::path::PathBuf; +use std::process::Command; + +/// Finds the location of an executable `name`. +fn find_executable(name: &str) -> Option { + const WHICH: &str = if cfg!(windows) { "where" } else { "which" }; + let cmd = Command::new(WHICH).arg(name).output().ok()?; + if cmd.status.success() { + let stdout = String::from_utf8_lossy(&cmd.stdout); + stdout.trim().lines().next().map(|l| l.trim().into()) + } else { + None + } +} + +/// Finds the location of the PHP executable. +fn find_php() -> Option { + // If path is given via env, it takes priority. + if let Some(path) = std::env::var_os("PHP").map(PathBuf::from) + && path.try_exists().unwrap_or(false) + { + return Some(path); + } + find_executable("php") +} + +/// Get PHP version as a (major, minor) tuple. +fn get_php_version() -> Option<(u32, u32)> { + let php = find_php()?; + let output = Command::new(&php) + .arg("-r") + .arg("echo PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;") + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let version_str = String::from_utf8_lossy(&output.stdout); + let parts: Vec<&str> = version_str.trim().split('.').collect(); + if parts.len() >= 2 { + let major = parts[0].parse().ok()?; + let minor = parts[1].parse().ok()?; + Some((major, minor)) + } else { + None + } +} + +fn main() { + // Declare check-cfg for all PHP version flags + println!("cargo::rustc-check-cfg=cfg(php80, php81, php82, php83, php84, php85)"); + + // Rerun if PHP environment changes + println!("cargo:rerun-if-env-changed=PHP"); + println!("cargo:rerun-if-env-changed=PATH"); + + let Some((major, minor)) = get_php_version() else { + eprintln!("Warning: Could not detect PHP version, DNF tests may not run"); + return; + }; + + // Set cumulative version flags (like ext-php-rs does) + // PHP 8.0 is baseline, no flag needed + if major >= 8 { + if minor >= 1 { + println!("cargo:rustc-cfg=php81"); + } + if minor >= 2 { + println!("cargo:rustc-cfg=php82"); + } + if minor >= 3 { + println!("cargo:rustc-cfg=php83"); + } + if minor >= 4 { + println!("cargo:rustc-cfg=php84"); + } + if minor >= 5 { + println!("cargo:rustc-cfg=php85"); + } + } +} diff --git a/tests/src/integration/class/class.php b/tests/src/integration/class/class.php index 77eccfd15..a1730d7f8 100644 --- a/tests/src/integration/class/class.php +++ b/tests/src/integration/class/class.php @@ -122,3 +122,70 @@ // Test returning &Self (immutable reference) $selfRef = $builder2->getSelf(); assert($selfRef === $builder2, 'getSelf should return $this'); + +// ==== Union types in class methods tests ==== + +// Helper to get type string from ReflectionType +function getTypeString(ReflectionType|null $type): string { + if ($type === null) { + return 'mixed'; + } + + if ($type instanceof ReflectionUnionType) { + $types = array_map(fn($t) => $t->getName(), $type->getTypes()); + sort($types); // Sort for consistent comparison + return implode('|', $types); + } + + if ($type instanceof ReflectionNamedType) { + $name = $type->getName(); + if ($type->allowsNull() && $name !== 'mixed' && $name !== 'null') { + return '?' . $name; + } + return $name; + } + + return (string)$type; +} + +// Test union type in instance method +$unionObj = new TestUnionMethods(); + +$method = new ReflectionMethod(TestUnionMethods::class, 'acceptIntOrString'); +$params = $method->getParameters(); +assert(count($params) === 1, 'acceptIntOrString should have 1 parameter'); + +$paramType = $params[0]->getType(); +assert($paramType instanceof ReflectionUnionType, 'Parameter should be a union type'); + +$typeStr = getTypeString($paramType); +assert($typeStr === 'int|string', "Expected 'int|string', got '$typeStr'"); + +// Call method with int +$result = $unionObj->acceptIntOrString(42); +assert($result === 'method_ok', 'Method should accept int'); + +// Call method with string +$result = $unionObj->acceptIntOrString("hello"); +assert($result === 'method_ok', 'Method should accept string'); + +// Test union type in static method +$method = new ReflectionMethod(TestUnionMethods::class, 'acceptFloatBoolNull'); +$params = $method->getParameters(); +$paramType = $params[0]->getType(); +assert($paramType instanceof ReflectionUnionType, 'Static method parameter should be a union type'); + +$typeStr = getTypeString($paramType); +assert($typeStr === 'bool|float|null', "Expected 'bool|float|null', got '$typeStr'"); + +// Call static method with various types +$result = TestUnionMethods::acceptFloatBoolNull(3.14); +assert($result === 'static_method_ok', 'Static method should accept float'); + +$result = TestUnionMethods::acceptFloatBoolNull(true); +assert($result === 'static_method_ok', 'Static method should accept bool'); + +$result = TestUnionMethods::acceptFloatBoolNull(null); +assert($result === 'static_method_ok', 'Static method should accept null'); + +echo "All class union type tests passed!\n"; diff --git a/tests/src/integration/class/mod.rs b/tests/src/integration/class/mod.rs index 0b38ecf95..021260d08 100644 --- a/tests/src/integration/class/mod.rs +++ b/tests/src/integration/class/mod.rs @@ -277,6 +277,27 @@ impl FluentBuilder { } } +/// Test class for union types in methods +#[php_class] +pub struct TestUnionMethods; + +#[php_impl] +impl TestUnionMethods { + pub fn __construct() -> Self { + Self + } + + /// Method accepting int|string union type + pub fn accept_int_or_string(#[php(union = "int|string")] _value: &Zval) -> String { + "method_ok".to_string() + } + + /// Static method accepting float|bool|null union type + pub fn accept_float_bool_null(#[php(union = "float|bool|null")] _value: &Zval) -> String { + "static_method_ok".to_string() + } +} + pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { builder .class::() @@ -287,6 +308,7 @@ pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { .class::() .class::() .class::() + .class::() .function(wrap_function!(test_class)) .function(wrap_function!(throw_exception)) } diff --git a/tests/src/integration/mod.rs b/tests/src/integration/mod.rs index 94313f756..f0af0a05b 100644 --- a/tests/src/integration/mod.rs +++ b/tests/src/integration/mod.rs @@ -19,6 +19,7 @@ pub mod object; pub mod persistent_string; pub mod string; pub mod types; +pub mod union; pub mod variadic_args; #[cfg(test)] diff --git a/tests/src/integration/union/mod.rs b/tests/src/integration/union/mod.rs new file mode 100644 index 000000000..df519c3b8 --- /dev/null +++ b/tests/src/integration/union/mod.rs @@ -0,0 +1,203 @@ +//! Integration tests for union and intersection types (PHP 8.0+) + +use ext_php_rs::args::Arg; +#[cfg(php82)] +use ext_php_rs::args::TypeGroup; +use ext_php_rs::builders::FunctionBuilder; +use ext_php_rs::flags::DataType; +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; +use ext_php_rs::zend::ExecuteData; + +// ==== Macro-based union type tests ==== + +/// Function using macro syntax for union types +/// Accepts int|string via #[php(types = "...")] +#[php_function] +pub fn test_macro_union_int_string(#[php(types = "int|string")] _value: &Zval) -> String { + "macro_ok".to_string() +} + +/// Function using macro syntax for union type with null +/// Accepts float|bool|null +#[php_function] +pub fn test_macro_union_float_bool_null(#[php(types = "float|bool|null")] _value: &Zval) -> String { + "macro_ok".to_string() +} + +// ==== Macro-based intersection type tests (PHP 8.1+) ==== + +/// Function using macro syntax for intersection types +/// Accepts Countable&Traversable +#[php_function] +pub fn test_macro_intersection(#[php(types = "Countable&Traversable")] _value: &Zval) -> String { + "macro_intersection_ok".to_string() +} + +// ==== DNF (Disjunctive Normal Form) type tests (PHP 8.2+) ==== + +/// Function using macro syntax for DNF types +/// Accepts (Countable&Traversable)|ArrayAccess +#[cfg(php82)] +#[php_function] +pub fn test_macro_dnf( + #[php(types = "(Countable&Traversable)|ArrayAccess")] _value: &Zval, +) -> String { + "macro_dnf_ok".to_string() +} + +/// Function using macro syntax for DNF with multiple intersection groups +/// Accepts (Countable&Traversable)|(Iterator&ArrayAccess) +#[cfg(php82)] +#[php_function] +pub fn test_macro_dnf_multi( + #[php(types = "(Countable&Traversable)|(Iterator&ArrayAccess)")] _value: &Zval, +) -> String { + "macro_dnf_multi_ok".to_string() +} + +/// Handler for `test_union_int_string` function. +/// Accepts `int|string` and returns the type name. +#[cfg(not(windows))] +extern "C" fn test_union_int_string_handler(_: &mut ExecuteData, retval: &mut Zval) { + // For now, just return "ok" to indicate we received the value + // The important part is that PHP reflection sees the correct union type + let _ = retval.set_string("ok", false); +} + +#[cfg(windows)] +extern "vectorcall" fn test_union_int_string_handler(_: &mut ExecuteData, retval: &mut Zval) { + let _ = retval.set_string("ok", false); +} + +/// Handler for `test_union_int_string_null` function. +/// Accepts `int|string|null`. +#[cfg(not(windows))] +extern "C" fn test_union_int_string_null_handler(_: &mut ExecuteData, retval: &mut Zval) { + let _ = retval.set_string("ok", false); +} + +#[cfg(windows)] +extern "vectorcall" fn test_union_int_string_null_handler(_: &mut ExecuteData, retval: &mut Zval) { + let _ = retval.set_string("ok", false); +} + +/// Handler for `test_union_array_bool` function. +/// Accepts `array|bool`. +#[cfg(not(windows))] +extern "C" fn test_union_array_bool_handler(_: &mut ExecuteData, retval: &mut Zval) { + let _ = retval.set_string("ok", false); +} + +#[cfg(windows)] +extern "vectorcall" fn test_union_array_bool_handler(_: &mut ExecuteData, retval: &mut Zval) { + let _ = retval.set_string("ok", false); +} + +/// Handler for `test_intersection_countable_traversable` function. +/// Accepts `Countable&Traversable` (PHP 8.1+). +#[cfg(not(windows))] +extern "C" fn test_intersection_handler(_: &mut ExecuteData, retval: &mut Zval) { + let _ = retval.set_string("intersection_ok", false); +} + +#[cfg(windows)] +extern "vectorcall" fn test_intersection_handler(_: &mut ExecuteData, retval: &mut Zval) { + let _ = retval.set_string("intersection_ok", false); +} + +/// Handler for `test_dnf` function. +/// Accepts `(Countable&Traversable)|ArrayAccess` (PHP 8.2+). +#[cfg(all(php82, not(windows)))] +extern "C" fn test_dnf_handler(_: &mut ExecuteData, retval: &mut Zval) { + let _ = retval.set_string("dnf_ok", false); +} + +#[cfg(all(php82, windows))] +extern "vectorcall" fn test_dnf_handler(_: &mut ExecuteData, retval: &mut Zval) { + let _ = retval.set_string("dnf_ok", false); +} + +pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { + // Function with int|string parameter + let union_int_string = + FunctionBuilder::new("test_union_int_string", test_union_int_string_handler) + .arg(Arg::new_union( + "value", + vec![DataType::Long, DataType::String], + )) + .returns(DataType::String, false, false); + + // Function with int|string|null parameter + let union_int_string_null = FunctionBuilder::new( + "test_union_int_string_null", + test_union_int_string_null_handler, + ) + .arg(Arg::new_union( + "value", + vec![DataType::Long, DataType::String, DataType::Null], + )) + .returns(DataType::String, false, false); + + // Function with array|bool parameter + let union_array_bool = + FunctionBuilder::new("test_union_array_bool", test_union_array_bool_handler) + .arg(Arg::new_union( + "value", + vec![DataType::Array, DataType::Bool], + )) + .returns(DataType::String, false, false); + + // Function with intersection type Countable&Traversable (PHP 8.1+) + let intersection_countable_traversable = FunctionBuilder::new( + "test_intersection_countable_traversable", + test_intersection_handler, + ) + .arg(Arg::new_intersection( + "value", + vec!["Countable".to_string(), "Traversable".to_string()], + )) + .returns(DataType::String, false, false); + + let builder = builder + .function(union_int_string) + .function(union_int_string_null) + .function(union_array_bool) + .function(wrap_function!(test_macro_union_int_string)) + .function(wrap_function!(test_macro_union_float_bool_null)) + .function(intersection_countable_traversable) + .function(wrap_function!(test_macro_intersection)); + + // DNF types are PHP 8.2+ only + #[cfg(php82)] + let builder = { + // Function with DNF type (Countable&Traversable)|ArrayAccess (PHP 8.2+) + let dnf_type = FunctionBuilder::new("test_dnf", test_dnf_handler) + .arg(Arg::new_dnf( + "value", + vec![ + TypeGroup::Intersection(vec![ + "Countable".to_string(), + "Traversable".to_string(), + ]), + TypeGroup::Single("ArrayAccess".to_string()), + ], + )) + .returns(DataType::String, false, false); + + builder + .function(dnf_type) + .function(wrap_function!(test_macro_dnf)) + .function(wrap_function!(test_macro_dnf_multi)) + }; + + builder +} + +#[cfg(test)] +mod tests { + #[test] + fn union_types_work() { + assert!(crate::integration::test::run_php("union/union.php")); + } +} diff --git a/tests/src/integration/union/union.php b/tests/src/integration/union/union.php new file mode 100644 index 000000000..9e8e0ae43 --- /dev/null +++ b/tests/src/integration/union/union.php @@ -0,0 +1,263 @@ + $t->getName(), $type->getTypes()); + sort($types); // Sort for consistent comparison + return implode('|', $types); + } + + if ($type instanceof ReflectionNamedType) { + $name = $type->getName(); + if ($type->allowsNull() && $name !== 'mixed' && $name !== 'null') { + return '?' . $name; + } + return $name; + } + + return (string)$type; +} + +// Test int|string union type +$func = new ReflectionFunction('test_union_int_string'); +$params = $func->getParameters(); +assert(count($params) === 1, 'test_union_int_string should have 1 parameter'); + +$paramType = $params[0]->getType(); +assert($paramType instanceof ReflectionUnionType, 'Parameter should be a union type'); + +$typeStr = getTypeString($paramType); +assert($typeStr === 'int|string', "Expected 'int|string', got '$typeStr'"); + +// Call the function with int +$result = test_union_int_string(42); +assert($result === 'ok', 'Function should accept int'); + +// Call the function with string +$result = test_union_int_string("hello"); +assert($result === 'ok', 'Function should accept string'); + +// Test int|string|null union type +$func = new ReflectionFunction('test_union_int_string_null'); +$params = $func->getParameters(); +$paramType = $params[0]->getType(); +assert($paramType instanceof ReflectionUnionType, 'Parameter should be a union type'); + +$typeStr = getTypeString($paramType); +assert($typeStr === 'int|null|string', "Expected 'int|null|string', got '$typeStr'"); + +// Call with null +$result = test_union_int_string_null(null); +assert($result === 'ok', 'Function should accept null'); + +// Test array|bool union type +$func = new ReflectionFunction('test_union_array_bool'); +$params = $func->getParameters(); +$paramType = $params[0]->getType(); +assert($paramType instanceof ReflectionUnionType, 'Parameter should be a union type'); + +$typeStr = getTypeString($paramType); +assert($typeStr === 'array|bool', "Expected 'array|bool', got '$typeStr'"); + +// Call with array +$result = test_union_array_bool([1, 2, 3]); +assert($result === 'ok', 'Function should accept array'); + +// Call with bool +$result = test_union_array_bool(true); +assert($result === 'ok', 'Function should accept bool'); + +// ==== Macro-based union type tests ==== + +// Test macro-based int|string union type +$func = new ReflectionFunction('test_macro_union_int_string'); +$params = $func->getParameters(); +assert(count($params) === 1, 'test_macro_union_int_string should have 1 parameter'); + +$paramType = $params[0]->getType(); +assert($paramType instanceof ReflectionUnionType, 'Parameter should be a union type'); + +$typeStr = getTypeString($paramType); +assert($typeStr === 'int|string', "Expected 'int|string', got '$typeStr'"); + +// Call macro function with int +$result = test_macro_union_int_string(42); +assert($result === 'macro_ok', 'Macro function should accept int'); + +// Call macro function with string +$result = test_macro_union_int_string("hello"); +assert($result === 'macro_ok', 'Macro function should accept string'); + +// Test macro-based float|bool|null union type +$func = new ReflectionFunction('test_macro_union_float_bool_null'); +$params = $func->getParameters(); +$paramType = $params[0]->getType(); +assert($paramType instanceof ReflectionUnionType, 'Parameter should be a union type'); + +$typeStr = getTypeString($paramType); +assert($typeStr === 'bool|float|null', "Expected 'bool|float|null', got '$typeStr'"); + +// Call with float +$result = test_macro_union_float_bool_null(3.14); +assert($result === 'macro_ok', 'Macro function should accept float'); + +// Call with bool +$result = test_macro_union_float_bool_null(false); +assert($result === 'macro_ok', 'Macro function should accept bool'); + +// Call with null +$result = test_macro_union_float_bool_null(null); +assert($result === 'macro_ok', 'Macro function should accept null'); + +// ==== Intersection type tests ==== +// Note: Intersection types for internal function parameters require PHP 8.3+ +// On PHP 8.1/8.2, intersection types exist for userland code but internal functions +// fall back to 'mixed' type. See: https://github.com/php/php-src/pull/11969 +if (PHP_VERSION_ID >= 80100) { + $arrayIterator = new ArrayIterator([1, 2, 3]); + + // Function calls work on PHP 8.1+ (the function itself accepts the value) + echo "Testing intersection type function call (FunctionBuilder)...\n"; + $result = test_intersection_countable_traversable($arrayIterator); + assert($result === 'intersection_ok', 'Function should accept ArrayIterator'); + echo "Function call succeeded!\n"; + + echo "Testing macro-based intersection type function call...\n"; + $result = test_macro_intersection($arrayIterator); + assert($result === 'macro_intersection_ok', 'Macro function should accept ArrayIterator'); + echo "Macro function call succeeded!\n"; + + // Reflection tests for intersection types in internal functions require PHP 8.3+ + if (PHP_VERSION_ID >= 80300) { + // Now test intersection type via reflection + echo "Testing intersection type via reflection (FunctionBuilder)...\n"; + $func = new ReflectionFunction('test_intersection_countable_traversable'); + $params = $func->getParameters(); + assert(count($params) === 1, 'test_intersection_countable_traversable should have 1 parameter'); + + $paramType = $params[0]->getType(); + assert($paramType instanceof ReflectionIntersectionType, 'Parameter should be an intersection type'); + + $types = array_map(fn($t) => $t->getName(), $paramType->getTypes()); + sort($types); + $typeStr = implode('&', $types); + assert($typeStr === 'Countable&Traversable', "Expected 'Countable&Traversable', got '$typeStr'"); + + // Test macro intersection type via reflection + echo "Testing macro-based intersection type via reflection...\n"; + $func = new ReflectionFunction('test_macro_intersection'); + $params = $func->getParameters(); + assert(count($params) === 1, 'test_macro_intersection should have 1 parameter'); + + $paramType = $params[0]->getType(); + assert($paramType instanceof ReflectionIntersectionType, 'Macro parameter should be an intersection type'); + + $types = array_map(fn($t) => $t->getName(), $paramType->getTypes()); + sort($types); + $typeStr = implode('&', $types); + assert($typeStr === 'Countable&Traversable', "Expected 'Countable&Traversable' for macro, got '$typeStr'"); + + echo "All intersection type reflection tests passed!\n"; + } else { + echo "Skipping intersection type reflection tests (requires PHP 8.3+ for internal functions).\n"; + } + + echo "All intersection type function call tests passed!\n"; +} + +// ==== DNF type tests (PHP 8.2+) ==== +// Note: DNF types for internal function parameters require PHP 8.3+ +// See: https://github.com/php/php-src/pull/11969 +if (PHP_VERSION_ID >= 80200) { + $arrayIterator = new ArrayIterator([1, 2, 3]); + $arrayObject = new ArrayObject([1, 2, 3]); + + // Function calls work on PHP 8.2+ (the function itself accepts the value) + echo "Testing DNF type function call (FunctionBuilder)...\n"; + $result = test_dnf($arrayIterator); + assert($result === 'dnf_ok', 'Function should accept ArrayIterator (satisfies Countable&Traversable)'); + echo "Function call with intersection part succeeded!\n"; + + $result = test_dnf($arrayObject); + assert($result === 'dnf_ok', 'Function should accept ArrayObject (implements ArrayAccess)'); + echo "Function call with ArrayAccess part succeeded!\n"; + + echo "Testing macro-based DNF type function call...\n"; + $result = test_macro_dnf($arrayIterator); + assert($result === 'macro_dnf_ok', 'Macro function should accept ArrayIterator'); + echo "Macro function call succeeded!\n"; + + echo "Testing multi-intersection DNF type...\n"; + $result = test_macro_dnf_multi($arrayIterator); + assert($result === 'macro_dnf_multi_ok', 'Multi-intersection DNF should accept ArrayIterator'); + echo "Multi-intersection DNF function call succeeded!\n"; + + // Reflection tests for DNF types in internal functions require PHP 8.3+ + if (PHP_VERSION_ID >= 80300) { + // Test DNF type via reflection + echo "Testing DNF type via reflection (FunctionBuilder)...\n"; + $func = new ReflectionFunction('test_dnf'); + $params = $func->getParameters(); + assert(count($params) === 1, 'test_dnf should have 1 parameter'); + + $paramType = $params[0]->getType(); + assert($paramType instanceof ReflectionUnionType, 'Parameter should be a union type (DNF)'); + + // DNF types are unions at the top level, with intersection types as members + $types = $paramType->getTypes(); + assert(count($types) === 2, 'DNF type should have 2 members'); + + // Check that we have both an intersection type and a named type + $hasIntersection = false; + $hasNamed = false; + foreach ($types as $type) { + if ($type instanceof ReflectionIntersectionType) { + $hasIntersection = true; + $intersectionTypes = array_map(fn($t) => $t->getName(), $type->getTypes()); + sort($intersectionTypes); + assert($intersectionTypes === ['Countable', 'Traversable'], + 'Intersection should be Countable&Traversable'); + } elseif ($type instanceof ReflectionNamedType) { + $hasNamed = true; + assert($type->getName() === 'ArrayAccess', 'Named type should be ArrayAccess'); + } + } + assert($hasIntersection, 'DNF type should contain an intersection type'); + assert($hasNamed, 'DNF type should contain a named type'); + + // Test macro DNF type via reflection + echo "Testing macro-based DNF type via reflection...\n"; + $func = new ReflectionFunction('test_macro_dnf'); + $params = $func->getParameters(); + $paramType = $params[0]->getType(); + assert($paramType instanceof ReflectionUnionType, 'Macro parameter should be a union type (DNF)'); + + $func = new ReflectionFunction('test_macro_dnf_multi'); + $params = $func->getParameters(); + $paramType = $params[0]->getType(); + assert($paramType instanceof ReflectionUnionType, 'Multi DNF parameter should be a union type'); + + $types = $paramType->getTypes(); + assert(count($types) === 2, 'Multi DNF type should have 2 intersection members'); + foreach ($types as $type) { + assert($type instanceof ReflectionIntersectionType, 'Each member should be an intersection type'); + } + + echo "All DNF type reflection tests passed!\n"; + } else { + echo "Skipping DNF type reflection tests (requires PHP 8.3+ for internal functions).\n"; + } + + echo "All DNF type function call tests passed!\n"; +} + +echo "All union type tests passed!\n"; diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 2d261c6d1..7dbfd20b0 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -32,6 +32,7 @@ pub fn build_module(module: ModuleBuilder) -> ModuleBuilder { module = integration::object::build_module(module); module = integration::persistent_string::build_module(module); module = integration::string::build_module(module); + module = integration::union::build_module(module); module = integration::variadic_args::build_module(module); module = integration::interface::build_module(module);