From 40c425a8b13afa4291931852c45611281833ca3f 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 a6833e6019f469013fcc171f3bfed5d4880fabe0 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/cli/src/lib.rs | 11 +- crates/macros/src/function.rs | 479 +++++++++++++++++++++++- crates/macros/src/impl_.rs | 14 +- crates/macros/src/interface.rs | 11 + crates/macros/src/lib.rs | 336 ++++++++++++++++- crates/macros/src/parsing.rs | 15 +- crates/macros/src/union.rs | 370 +++++++++++++++++++ docsrs_bindings.rs | 9 + guide/src/macros/function.md | 235 ++++++++++++ guide/src/types/index.md | 13 + src/args.rs | 383 +++++++++++++++++++- src/builders/sapi.rs | 28 +- src/closure.rs | 3 +- src/convert.rs | 71 ++++ src/embed/mod.rs | 8 +- src/enum_.rs | 9 +- src/ffi.rs | 2 + src/lib.rs | 8 +- src/types/array/conversions/mod.rs | 6 +- src/types/zval.rs | 4 +- src/wrapper.c | 4 + src/wrapper.h | 1 + src/zend/_type.rs | 502 +++++++++++++++++++++++++- src/zend/bailout_guard.rs | 23 +- src/zend/mod.rs | 4 +- src/zend/try_catch.rs | 7 +- tests/Cargo.toml | 3 + tests/build.rs | 90 +++++ tests/src/integration/array/mod.rs | 3 +- tests/src/integration/bailout/mod.rs | 32 +- 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 | 249 +++++++++++++ tests/src/integration/union/union.php | 314 ++++++++++++++++ tests/src/lib.rs | 1 + 37 files changed, 3258 insertions(+), 84 deletions(-) create mode 100644 crates/macros/src/union.rs 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 1146aa249..5c16e58d3 100644 --- a/allowed_bindings.rs +++ b/allowed_bindings.rs @@ -251,6 +251,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/cli/src/lib.rs b/crates/cli/src/lib.rs index e363066d7..183868e87 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -225,7 +225,8 @@ impl Install { } // Use atomic copy: copy to temp file in same directory, then rename. - // This prevents race conditions where a partially-written extension could be loaded. + // This prevents race conditions where a partially-written extension could be + // loaded. let temp_ext_path = ext_dir.with_extension(format!( "{}.tmp.{}", ext_dir @@ -239,15 +240,17 @@ impl Install { || "Failed to copy extension from target directory to extension directory", )?; - // Rename is atomic on POSIX when source and destination are on the same filesystem + // Rename is atomic on POSIX when source and destination are on the same + // filesystem if let Err(e) = std::fs::rename(&temp_ext_path, &ext_dir) { // Clean up temp file on failure let _ = std::fs::remove_file(&temp_ext_path); return Err(e).with_context(|| "Failed to rename extension to final destination"); } - // Smoke test: verify the extension loads correctly before enabling it in php.ini. - // This prevents broken extensions from crashing PHP on startup. + // Smoke test: verify the extension loads correctly before enabling it in + // php.ini. This prevents broken extensions from crashing PHP on + // startup. if !self.no_smoke_test { let smoke_test = Command::new("php") .arg("-d") diff --git a/crates/macros/src/function.rs b/crates/macros/src/function.rs index 9687b50e9..31b5e41c7 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,34 @@ 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), + /// A Rust enum type that implements `PhpUnion` trait. + /// The union types are determined at runtime via `PhpUnion::union_types()`. + UnionEnum, +} + #[derive(Debug)] pub struct TypedArg<'a> { pub name: &'a Ident, @@ -610,6 +646,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 +679,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 +698,7 @@ impl<'a> Args<'a> { default, as_ref, variadic, + php_type, }); } } @@ -746,6 +789,346 @@ impl<'a> Args<'a> { None => (&self.typed[..], &self.typed[0..0]), } } + + /// Parses `#[php(types = "...")]`, `#[php(union = "...")]`, or + /// `#[php(union_enum)]` 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 + /// - `#[php(union_enum)]` - use `PhpUnion::union_types()` for Rust enum + /// types + fn parse_type_attr(attrs: &[syn::Attribute]) -> Result> { + for attr in attrs { + if !attr.path().is_ident("php") { + continue; + } + + // Parse #[php(types = "...")], #[php(union = "...")], or #[php(union_enum)] + let nested = attr.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + )?; + + for meta in nested { + // Check for #[php(union_enum)] - a path without value + if let syn::Meta::Path(path) = &meta + && path.is_ident("union_enum") + { + return Ok(Some(PhpTypeDecl::UnionEnum)); + } + + // Check for #[php(types = "...")] or #[php(union = "...")] + 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 trimmed = type_name.trim(); + + // Emit deprecation warnings for aliases deprecated in PHP 8.5 + // See: https://php.watch/versions/8.5/boolean-double-integer-binary-casts-deprecated + let deprecated_warning = match trimmed { + "boolean" => Some(("boolean", "bool")), + "integer" => Some(("integer", "int")), + "double" => Some(("double", "float")), + "binary" => Some(("binary", "string")), + _ => None, + }; + + if let Some((old, new)) = deprecated_warning { + // Emit a compile-time warning for deprecated type aliases. + // This generates a #[deprecated] item that triggers a warning. + let warning_fn = syn::Ident::new( + &format!("__ext_php_rs_deprecated_{old}"), + proc_macro2::Span::call_site(), + ); + let msg = format!("The type alias '{old}' is deprecated in PHP 8.5+. Use '{new}' instead."); + // We return the tokens that include a deprecated function call to trigger + // warning + let data_type = match trimmed { + "boolean" => quote! { ::ext_php_rs::flags::DataType::Bool }, + "integer" => quote! { ::ext_php_rs::flags::DataType::Long }, + "double" => quote! { ::ext_php_rs::flags::DataType::Double }, + "binary" => quote! { ::ext_php_rs::flags::DataType::String }, + _ => unreachable!(), + }; + return Some(quote! {{ + #[deprecated(note = #msg)] + #[allow(non_snake_case)] + const fn #warning_fn() {} + #[cfg(php85)] + { let _ = #warning_fn(); } + #data_type + }}); + } + + let tokens = match trimmed { + "int" | "long" => quote! { ::ext_php_rs::flags::DataType::Long }, + "string" => quote! { ::ext_php_rs::flags::DataType::String }, + "bool" => quote! { ::ext_php_rs::flags::DataType::Bool }, + "float" => 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<'_> { @@ -782,6 +1165,7 @@ impl TypedArg<'_> { /// Returns a token stream containing the `Arg` definition to be passed to /// `ext-php-rs`. + #[allow(clippy::too_many_lines)] fn arg_builder(&self) -> TokenStream { let name = ident_to_php_name(self.name); let ty = self.clean_ty(); @@ -802,6 +1186,99 @@ 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 + } + } + PhpTypeDecl::UnionEnum => { + // Use PhpUnion::union_types() to get union types from the Rust enum. + // The result can be either Simple (Vec) or Dnf (Vec). + quote! { + { + let union_types = <#ty as ::ext_php_rs::convert::PhpUnion>::union_types(); + match union_types { + ::ext_php_rs::convert::PhpUnionTypes::Simple(types) => { + ::ext_php_rs::args::Arg::new_union(#name, types) + #null + #default + #as_ref + #variadic + } + ::ext_php_rs::convert::PhpUnionTypes::Dnf(groups) => { + ::ext_php_rs::args::Arg::new_dnf(#name, groups) + #null + #default + #as_ref + #variadic + } + } + } + } + } + }; + } + quote! { ::ext_php_rs::args::Arg::new(#name, <#ty as ::ext_php_rs::convert::FromZvalMut>::TYPE) #null 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..e0b3a4a08 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -12,6 +12,7 @@ mod interface; mod module; mod parsing; mod syn_ext; +mod union; mod zval; use proc_macro::TokenStream; @@ -651,6 +652,250 @@ 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() {} +/// ``` +/// +/// ### Union Types as Rust Enums (`PhpUnion`) +/// +/// For a more ergonomic experience with union types, you can represent them as +/// Rust enums using the `#[derive(PhpUnion)]` macro. This allows you to use +/// Rust's pattern matching instead of manually checking the `Zval` type: +/// +/// ```rust,no_run,ignore +/// # #![cfg_attr(windows, feature(abi_vectorcall))] +/// # extern crate ext_php_rs; +/// use ext_php_rs::prelude::*; +/// +/// /// Rust enum representing PHP `int|string` +/// #[derive(Debug, Clone, PhpUnion)] +/// enum IntOrString { +/// Int(i64), +/// Str(String), +/// } +/// +/// /// Accepts int|string and processes it using Rust pattern matching +/// #[php_function] +/// fn process_value(#[php(union_enum)] value: IntOrString) -> String { +/// match value { +/// IntOrString::Int(n) => format!("Got integer: {}", n), +/// IntOrString::Str(s) => format!("Got string: {}", s), +/// } +/// } +/// # fn main() {} +/// ``` +/// +/// The `#[derive(PhpUnion)]` macro: +/// - Generates `FromZval` and `IntoZval` implementations +/// - Tries each variant in order when converting from PHP values +/// - Provides `PhpUnion::union_types()` method returning the PHP types +/// +/// Use `#[php(union_enum)]` on the parameter to tell the macro to use the +/// `PhpUnion` trait for type registration. +/// +/// **Requirements:** +/// - Each enum variant must be a tuple variant with exactly one field +/// - The field type must implement `FromZval` and `IntoZval` +/// - No unit variants or named fields are allowed +/// +/// #### Class and Interface Unions +/// +/// For union types that include PHP classes or interfaces, use the `#[php(class +/// = "...")]` or `#[php(interface = "...")]` attributes on enum variants. These +/// attributes override the PHP type declaration for the variant: +/// +/// ```rust,ignore +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::types::Zval; +/// +/// /// Rust enum representing PHP `Iterator|int` +/// #[derive(PhpUnion)] +/// enum IteratorOrInt<'a> { +/// #[php(interface = "Iterator")] +/// Iter(&'a Zval), // Use &Zval for object types +/// Int(i64), +/// } +/// +/// #[php_function] +/// fn process_iterator_or_int(#[php(union_enum)] value: IteratorOrInt) -> String { +/// match value { +/// IteratorOrInt::Iter(_) => "Got iterator".to_string(), +/// IteratorOrInt::Int(n) => format!("Got int: {n}"), +/// } +/// } +/// ``` +/// +/// This generates PHP: `function process_iterator_or_int(Iterator|int $value): +/// string` +/// +/// **Variant Attributes:** +/// - `#[php(class = "ClassName")]` - For PHP class types +/// - `#[php(interface = "InterfaceName")]` - For PHP interface types +/// - `#[php(intersection = ["Interface1", "Interface2"])]` - For intersection +/// types (PHP 8.2+ DNF) +/// +/// > **Note:** For class/interface types, use `&Zval` or `&mut Zval` as the +/// > variant field +/// > type since owned `ZendObject` doesn't implement the required traits. +/// +/// #### DNF Types (PHP 8.2+) +/// +/// For PHP 8.2+ DNF (Disjunctive Normal Form) types that combine unions and +/// intersections, use the `#[php(intersection = [...])]` attribute: +/// +/// ```rust,ignore +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::types::Zval; +/// +/// /// Represents: (Countable&Traversable)|ArrayAccess +/// #[derive(PhpUnion)] +/// enum CountableTraversableOrArrayAccess<'a> { +/// #[php(intersection = ["Countable", "Traversable"])] +/// CountableTraversable(&'a Zval), +/// #[php(interface = "ArrayAccess")] +/// ArrayAccess(&'a Zval), +/// } +/// +/// #[php_function] +/// fn process_dnf(#[php(union_enum)] _value: CountableTraversableOrArrayAccess) -> String { +/// "ok".to_string() +/// } +/// ``` +/// +/// This generates PHP: `function +/// process_dnf((Countable&Traversable)|ArrayAccess $value): string` +/// +/// > **Note:** When any variant uses `#[php(intersection = [...])]`, the macro +/// > automatically switches to DNF mode for all variants. +/// +/// For complex object union types, consider using the macro-based syntax +/// `#[php(types = "...")]` which provides more flexibility: +/// +/// ### 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 @@ -1293,6 +1538,91 @@ fn zval_convert_derive_internal(input: TokenStream2) -> TokenStream2 { zval::parser(input).unwrap_or_else(|e| e.to_compile_error()) } +/// # `PhpUnion` Derive Macro +/// +/// The `#[derive(PhpUnion)]` macro derives `FromZval`, `IntoZval`, and +/// `PhpUnion` traits for enums that represent PHP union types like +/// `int|string`. +/// +/// Each enum variant must be a tuple variant with exactly one field. The +/// field's type determines the corresponding PHP type in the union. +/// +/// When used with `#[php_function]`, the parameter will automatically have the +/// correct union type signature in PHP. +/// +/// ## Example +/// +/// ```rust,ignore +/// use ext_php_rs::prelude::*; +/// +/// #[derive(Debug, Clone, PhpUnion)] +/// enum IntOrString { +/// Int(i64), +/// Str(String), +/// } +/// +/// #[php_function] +/// fn process_value(value: IntOrString) -> String { +/// match value { +/// IntOrString::Int(n) => format!("Got int: {n}"), +/// IntOrString::Str(s) => format!("Got string: {s}"), +/// } +/// } +/// ``` +/// +/// This generates the PHP signature: `function process_value(int|string +/// $value): string` +/// +/// ## Variant Order Matters +/// +/// When a PHP value is passed in, the variants are tried in order. The first +/// variant whose inner type successfully converts from the PHP value is used. +/// +/// For example, with `IntOrString`, if PHP passes `42`, the `Int` variant is +/// tried first. Since `i64::from_zval` succeeds, `IntOrString::Int(42)` is +/// returned. +/// +/// ## Supported Types +/// +/// Any type that implements `FromZval` and `IntoZval` can be used as a +/// variant's inner type. Common types include: +/// +/// - `i64`, `i32`, etc. → PHP `int` +/// - `f64` → PHP `float` +/// - `bool` → PHP `bool` +/// - `String` → PHP `string` +/// - `Vec` / `HashMap` → PHP `array` +/// - `Option` → adds `null` to the union +/// +/// ## Class and Interface Types +/// +/// For variants that hold PHP class or interface types, use the +/// `#[php(class = "...")]` or `#[php(interface = "...")]` attributes: +/// +/// ```rust,ignore +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::types::ZendObject; +/// +/// #[derive(PhpUnion)] +/// enum IteratorOrInt { +/// #[php(interface = "Iterator")] +/// Iter(ZendObject), +/// Int(i64), +/// } +/// ``` +/// +/// This generates PHP signature with `Iterator|int` union type. +#[proc_macro_derive(PhpUnion, attributes(php))] +pub fn php_union_derive(input: TokenStream) -> TokenStream { + php_union_derive_internal(input.into()).into() +} + +fn php_union_derive_internal(input: TokenStream2) -> TokenStream2 { + let input = parse_macro_input2!(input as DeriveInput); + + union::parser(input).unwrap_or_else(|e| e.to_compile_error()) +} + /// Defines an `extern` function with the Zend fastcall convention based on /// operating system. /// @@ -1429,6 +1759,7 @@ mod tests { type AttributeFn = fn(proc_macro2::TokenStream, proc_macro2::TokenStream) -> proc_macro2::TokenStream; type FunctionLikeFn = fn(proc_macro2::TokenStream) -> proc_macro2::TokenStream; + type DeriveFn = fn(proc_macro2::TokenStream) -> proc_macro2::TokenStream; #[test] pub fn test_expand() { @@ -1476,7 +1807,10 @@ mod tests { let file = std::fs::File::open(path).expect("Failed to open expand test file"); runtime_macros::emulate_derive_macro_expansion( file, - &[("ZvalConvert", zval_convert_derive_internal)], + &[ + ("ZvalConvert", zval_convert_derive_internal as DeriveFn), + ("PhpUnion", php_union_derive_internal as DeriveFn), + ], ) .expect("Failed to expand derive macros in test file"); } 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/crates/macros/src/union.rs b/crates/macros/src/union.rs new file mode 100644 index 000000000..4865ea92c --- /dev/null +++ b/crates/macros/src/union.rs @@ -0,0 +1,370 @@ +//! Implementation of the `#[derive(PhpUnion)]` macro for representing PHP union +//! types as Rust enums. + +use darling::ToTokens; +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::{ + DataEnum, DeriveInput, GenericParam, Generics, Ident, Lifetime, LifetimeParam, Type, + WhereClause, punctuated::Punctuated, token::Where, +}; + +use crate::prelude::*; + +/// Information about a variant's PHP type override from attributes. +#[derive(Debug, Clone)] +enum PhpTypeOverride { + /// Use the default `FromZval::TYPE` + Default, + /// A PHP class name: `#[php(class = "MyClass")]` + Class(String), + /// A PHP interface name: `#[php(interface = "Iterator")]` + Interface(String), + /// An intersection of interfaces: `#[php(intersection = ["Countable", + /// "Traversable"])]` + Intersection(Vec), +} + +impl PhpTypeOverride { + /// Returns true if this override creates an intersection group (requires + /// DNF). + fn is_intersection(&self) -> bool { + matches!(self, PhpTypeOverride::Intersection(_)) + } +} + +/// Parses `#[php(...)]` attributes from variant attributes. +/// +/// Supports: +/// - `#[php(class = "MyClass")]` - single class +/// - `#[php(interface = "Iterator")]` - single interface +/// - `#[php(intersection = ["Countable", "Traversable"])]` - intersection group +fn parse_variant_php_attr(attrs: &[syn::Attribute]) -> Result { + for attr in attrs { + if !attr.path().is_ident("php") { + continue; + } + + let nested = attr.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + )?; + + for meta in nested { + if let syn::Meta::NameValue(nv) = &meta { + // Handle string value attributes: class = "...", interface = "..." + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = &nv.value + { + let value = lit_str.value(); + if nv.path.is_ident("class") { + return Ok(PhpTypeOverride::Class(value)); + } else if nv.path.is_ident("interface") { + return Ok(PhpTypeOverride::Interface(value)); + } + } + + // Handle array value: intersection = ["A", "B"] + if nv.path.is_ident("intersection") { + if let syn::Expr::Array(arr) = &nv.value { + let names: Result> = arr + .elems + .iter() + .map(|elem| { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + }) = elem + { + Ok(s.value()) + } else { + bail!(elem => "intersection array elements must be string literals") + } + }) + .collect(); + let names = names?; + if names.len() < 2 { + bail!(arr => "intersection requires at least 2 interfaces"); + } + return Ok(PhpTypeOverride::Intersection(names)); + } + bail!(nv.value => "intersection must be an array, e.g., intersection = [\"A\", \"B\"]"); + } + } + } + } + Ok(PhpTypeOverride::Default) +} + +pub fn parser(input: DeriveInput) -> Result { + let DeriveInput { + generics, ident, .. + } = input; + + let (into_impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + + let mut into_where_clause = where_clause.cloned().unwrap_or_else(|| WhereClause { + where_token: Where { + span: Span::call_site(), + }, + predicates: Punctuated::default(), + }); + let mut from_where_clause = into_where_clause.clone(); + + // Add lifetime for FromZval implementation + let from_impl_generics = { + let tokens = into_impl_generics.to_token_stream(); + let mut parsed: Generics = syn::parse2(tokens).expect("couldn't reparse generics"); + parsed + .params + .push(GenericParam::Lifetime(LifetimeParam::new(Lifetime::new( + "'_zval", + Span::call_site(), + )))); + parsed + }; + + // Add trait bounds for generic types + for generic in &generics.params { + match generic { + GenericParam::Type(ty) => { + let type_ident = &ty.ident; + into_where_clause.predicates.push( + syn::parse2(quote! { + #type_ident: ::ext_php_rs::convert::IntoZval + }) + .expect("couldn't parse where predicate"), + ); + from_where_clause.predicates.push( + syn::parse2(quote! { + #type_ident: ::ext_php_rs::convert::FromZval<'_zval> + }) + .expect("couldn't parse where predicate"), + ); + } + GenericParam::Lifetime(lt) => from_where_clause.predicates.push( + syn::parse2(quote! { + '_zval: #lt + }) + .expect("couldn't parse where predicate"), + ), + GenericParam::Const(_) => {} + } + } + + match input.data { + syn::Data::Enum(data) => parse_enum( + &data, + &ident, + &into_impl_generics, + &from_impl_generics, + &into_where_clause, + &from_where_clause, + &ty_generics, + ), + syn::Data::Struct(_) => { + bail!(ident.span() => "Only enums are supported by the `#[derive(PhpUnion)]` macro. For structs, use `#[derive(ZvalConvert)]` instead.") + } + syn::Data::Union(_) => { + bail!(ident.span() => "Only enums are supported by the `#[derive(PhpUnion)]` macro.") + } + } +} + +/// Information collected from parsing a variant. +struct VariantInfo { + /// The Rust type of the variant's field + field_ty: Type, + /// PHP type override from attributes (class/interface name) + php_override: PhpTypeOverride, +} + +#[allow(clippy::too_many_lines)] +fn parse_enum( + data: &DataEnum, + ident: &Ident, + into_impl_generics: &syn::ImplGenerics, + from_impl_generics: &Generics, + into_where_clause: &WhereClause, + from_where_clause: &WhereClause, + ty_generics: &syn::TypeGenerics, +) -> Result { + if data.variants.is_empty() { + bail!(ident.span() => "PhpUnion enum must have at least one variant."); + } + + // Collect variant info for code generation + let mut variant_infos = Vec::new(); + let mut into_variants = Vec::new(); + let mut from_variants = Vec::new(); + + for variant in &data.variants { + let variant_ident = &variant.ident; + let fields = &variant.fields; + + // Parse PHP type override from variant attributes + let php_override = parse_variant_php_attr(&variant.attrs)?; + + // PhpUnion only supports single-field tuple variants (no unit or named + // variants) + match fields { + syn::Fields::Unnamed(unnamed) => { + if unnamed.unnamed.len() != 1 { + bail!( + unnamed => + "PhpUnion enum variants must have exactly one field. For example: `Int(i64)` or `Str(String)`." + ); + } + + let field_ty = &unnamed.unnamed.first().unwrap().ty; + variant_infos.push(VariantInfo { + field_ty: field_ty.clone(), + php_override, + }); + + into_variants.push(quote! { + #ident::#variant_ident(val) => val.set_zval(zv, persistent) + }); + + from_variants.push(quote! { + if let ::std::option::Option::Some(value) = <#field_ty>::from_zval(zval) { + return ::std::option::Option::Some(Self::#variant_ident(value)); + } + }); + } + syn::Fields::Unit => { + bail!( + variant => + "PhpUnion enum variants cannot be unit variants. Each variant must wrap a type, e.g., `Int(i64)`." + ); + } + syn::Fields::Named(_) => { + bail!( + variant => + "PhpUnion enum variants must use tuple syntax, e.g., `Int(i64)`, not named fields." + ); + } + } + } + + // Check if any variant uses intersection (requires DNF output) + let has_intersection = variant_infos + .iter() + .any(|info| info.php_override.is_intersection()); + + // Generate the union_types() method body + // If any variant uses intersection, we generate DNF (Vec) + // Otherwise, we generate simple union (Vec) + let union_types_body = if has_intersection { + // DNF mode: generate Vec + let type_group_tokens: Vec<_> = variant_infos + .iter() + .map(|info| { + match &info.php_override { + PhpTypeOverride::Default => { + // For default types, we need to convert DataType to a class name + // This only works for Object types; primitives in DNF are not supported + let ty = &info.field_ty; + quote! { + { + // Get the type and convert to TypeGroup::Single if it's an object + let dt = <#ty as ::ext_php_rs::convert::FromZval>::TYPE; + match dt { + ::ext_php_rs::flags::DataType::Object(Some(name)) => { + ::ext_php_rs::args::TypeGroup::Single(name.to_string()) + } + _ => panic!("DNF types only support class/interface types, not primitives. Use #[php(class/interface = \"...\")] or #[php(intersection = [...])] on all variants.") + } + } + } + } + PhpTypeOverride::Class(name) | PhpTypeOverride::Interface(name) => { + quote! { + ::ext_php_rs::args::TypeGroup::Single(#name.to_string()) + } + } + PhpTypeOverride::Intersection(names) => { + quote! { + ::ext_php_rs::args::TypeGroup::Intersection( + ::std::vec![#(#names.to_string()),*] + ) + } + } + } + }) + .collect(); + + quote! { + ::ext_php_rs::convert::PhpUnionTypes::Dnf( + ::std::vec![#(#type_group_tokens),*] + ) + } + } else { + // Simple mode: generate Vec + let type_tokens: Vec<_> = variant_infos + .iter() + .map(|info| match &info.php_override { + PhpTypeOverride::Default => { + let ty = &info.field_ty; + quote! { + <#ty as ::ext_php_rs::convert::FromZval>::TYPE + } + } + PhpTypeOverride::Class(name) | PhpTypeOverride::Interface(name) => { + quote! { + ::ext_php_rs::flags::DataType::Object(::std::option::Option::Some(#name)) + } + } + PhpTypeOverride::Intersection(_) => { + unreachable!("intersection should trigger DNF mode") + } + }) + .collect(); + + quote! { + ::ext_php_rs::convert::PhpUnionTypes::Simple( + ::std::vec![#(#type_tokens),*] + ) + } + }; + + Ok(quote! { + impl #into_impl_generics ::ext_php_rs::convert::IntoZval for #ident #ty_generics #into_where_clause { + const TYPE: ::ext_php_rs::flags::DataType = ::ext_php_rs::flags::DataType::Mixed; + const NULLABLE: bool = false; + + fn set_zval( + self, + zv: &mut ::ext_php_rs::types::Zval, + persistent: bool, + ) -> ::ext_php_rs::error::Result<()> { + use ::ext_php_rs::convert::IntoZval; + + match self { + #(#into_variants,)* + } + } + } + + impl #from_impl_generics ::ext_php_rs::convert::FromZval<'_zval> for #ident #ty_generics #from_where_clause { + const TYPE: ::ext_php_rs::flags::DataType = ::ext_php_rs::flags::DataType::Mixed; + + fn from_zval(zval: &'_zval ::ext_php_rs::types::Zval) -> ::std::option::Option { + use ::ext_php_rs::convert::FromZval; + + #(#from_variants)* + + ::std::option::Option::None + } + } + + impl #from_impl_generics ::ext_php_rs::convert::PhpUnion<'_zval> for #ident #ty_generics #from_where_clause { + fn union_types() -> ::ext_php_rs::convert::PhpUnionTypes { + use ::ext_php_rs::convert::FromZval; + + #union_types_body + } + } + }) +} diff --git a/docsrs_bindings.rs b/docsrs_bindings.rs index d93b67b35..f1babc297 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; @@ -412,6 +415,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..042036e49 100644 --- a/guide/src/macros/function.md +++ b/guide/src/macros/function.md @@ -147,6 +147,241 @@ 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() {} +``` + +### Union Types as Rust Enums (`PhpUnion`) + +For a more ergonomic experience with union types, you can represent them as Rust +enums using the `#[derive(PhpUnion)]` macro. This allows you to use Rust's +pattern matching instead of manually checking the `Zval` type: + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; + +/// Rust enum representing PHP `int|string` +#[derive(Debug, Clone, PhpUnion)] +enum IntOrString { + Int(i64), + Str(String), +} + +/// Accepts int|string and processes it using Rust pattern matching +#[php_function] +fn process_value(#[php(union_enum)] value: IntOrString) -> String { + match value { + IntOrString::Int(n) => format!("Got integer: {}", n), + IntOrString::Str(s) => format!("Got string: {}", s), + } +} +# fn main() {} +``` + +The `#[derive(PhpUnion)]` macro: +- Generates `FromZval` and `IntoZval` implementations +- Tries each variant in order when converting from PHP values +- Provides `PhpUnion::union_types()` method returning the PHP types + +Use `#[php(union_enum)]` on the parameter to tell the macro to use the +`PhpUnion` trait for type registration. + +**Requirements:** +- Each enum variant must be a tuple variant with exactly one field +- The field type must implement `FromZval` and `IntoZval` +- No unit variants or named fields are allowed + +#### Class and Interface Unions + +For union types that include PHP classes or interfaces, use the `#[php(class = "...")]` +or `#[php(interface = "...")]` attributes on enum variants. These attributes override +the PHP type declaration for the variant: + +```rust,ignore +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; + +/// Rust enum representing PHP `Iterator|int` +#[derive(PhpUnion)] +enum IteratorOrInt<'a> { + #[php(interface = "Iterator")] + Iter(&'a Zval), // Use &Zval for object types + Int(i64), +} + +#[php_function] +fn process_iterator_or_int(#[php(union_enum)] value: IteratorOrInt) -> String { + match value { + IteratorOrInt::Iter(_) => "Got iterator".to_string(), + IteratorOrInt::Int(n) => format!("Got int: {n}"), + } +} +``` + +This generates PHP: `function process_iterator_or_int(Iterator|int $value): string` + +**Variant Attributes:** +- `#[php(class = "ClassName")]` - For PHP class types +- `#[php(interface = "InterfaceName")]` - For PHP interface types +- `#[php(intersection = ["Interface1", "Interface2"])]` - For intersection types (PHP 8.2+ DNF) + +> **Note:** For class/interface types, use `&Zval` or `&mut Zval` as the variant field +> type since owned `ZendObject` doesn't implement the required traits. + +#### DNF Types (PHP 8.2+) + +For PHP 8.2+ DNF (Disjunctive Normal Form) types that combine unions and intersections, +use the `#[php(intersection = [...])]` attribute: + +```rust,ignore +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; + +/// Represents: (Countable&Traversable)|ArrayAccess +#[derive(PhpUnion)] +enum CountableTraversableOrArrayAccess<'a> { + #[php(intersection = ["Countable", "Traversable"])] + CountableTraversable(&'a Zval), + #[php(interface = "ArrayAccess")] + ArrayAccess(&'a Zval), +} + +#[php_function] +fn process_dnf(#[php(union_enum)] _value: CountableTraversableOrArrayAccess) -> String { + "ok".to_string() +} +``` + +This generates PHP: `function process_dnf((Countable&Traversable)|ArrayAccess $value): string` + +> **Note:** When any variant uses `#[php(intersection = [...])]`, the macro +> automatically switches to DNF mode for all variants. + +For complex object union types, consider using the macro-based syntax +`#[php(types = "...")]` which provides more flexibility: + +### 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..5424f742a 100644 --- a/src/args.rs +++ b/src/args.rs @@ -18,12 +18,86 @@ 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 +116,149 @@ 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 +373,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 +469,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 +488,21 @@ 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 +871,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/builders/sapi.rs b/src/builders/sapi.rs index 1de957209..a9fc8898f 100644 --- a/src/builders/sapi.rs +++ b/src/builders/sapi.rs @@ -168,7 +168,8 @@ impl SapiBuilder { /// /// # Parameters /// - /// * `func` - The function to be called when PHP gets an environment variable. + /// * `func` - The function to be called when PHP gets an environment + /// variable. pub fn getenv_function(mut self, func: SapiGetEnvFunc) -> Self { self.module.getenv = Some(func); self @@ -196,7 +197,8 @@ impl SapiBuilder { /// Sets the send headers function for this SAPI /// - /// This function is called once when all headers are finalized and ready to send. + /// This function is called once when all headers are finalized and ready to + /// send. /// /// # Arguments /// @@ -230,7 +232,8 @@ impl SapiBuilder { /// /// # Parameters /// - /// * `func` - The function to be called when PHP registers server variables. + /// * `func` - The function to be called when PHP registers server + /// variables. pub fn register_server_variables_function( mut self, func: SapiRegisterServerVariablesFunc, @@ -291,8 +294,8 @@ impl SapiBuilder { /// Sets the pre-request init function for this SAPI /// - /// This function is called before request activation and before POST data is read. - /// It is typically used for .user.ini processing. + /// This function is called before request activation and before POST data + /// is read. It is typically used for .user.ini processing. /// /// # Parameters /// @@ -455,7 +458,8 @@ pub type SapiGetUidFunc = extern "C" fn(uid: *mut uid_t) -> c_int; /// A function to be called when PHP gets the gid pub type SapiGetGidFunc = extern "C" fn(gid: *mut gid_t) -> c_int; -/// A function to be called before request activation (used for .user.ini processing) +/// A function to be called before request activation (used for .user.ini +/// processing) #[cfg(php85)] pub type SapiPreRequestInitFunc = extern "C" fn() -> c_int; @@ -485,8 +489,9 @@ mod test { extern "C" fn test_getenv(_name: *const c_char, _name_length: usize) -> *mut c_char { ptr::null_mut() } - // Note: C-variadic functions are unstable in Rust, so we can't test this properly - // extern "C" fn test_sapi_error(_type: c_int, _error_msg: *const c_char, _args: ...) {} + // Note: C-variadic functions are unstable in Rust, so we can't test this + // properly extern "C" fn test_sapi_error(_type: c_int, _error_msg: *const + // c_char, _args: ...) {} extern "C" fn test_send_header(_header: *mut sapi_header_struct, _server_context: *mut c_void) { } extern "C" fn test_send_headers(_sapi_headers: *mut sapi_headers_struct) -> c_int { @@ -633,9 +638,10 @@ mod test { ); } - // Note: Cannot test sapi_error_function because C-variadic functions are unstable in Rust - // The sapi_error field accepts a function with variadic arguments which cannot be - // created in stable Rust. However, the builder method itself works correctly. + // Note: Cannot test sapi_error_function because C-variadic functions are + // unstable in Rust The sapi_error field accepts a function with variadic + // arguments which cannot be created in stable Rust. However, the builder + // method itself works correctly. #[test] fn test_send_header_function() { diff --git a/src/closure.rs b/src/closure.rs index f184ff8f4..f58c9a6a0 100644 --- a/src/closure.rs +++ b/src/closure.rs @@ -116,7 +116,8 @@ impl Closure { /// function. /// /// If the class has already been built, this function returns early without - /// doing anything. This allows for safe repeated calls in test environments. + /// doing anything. This allows for safe repeated calls in test + /// environments. /// /// # Panics /// diff --git a/src/convert.rs b/src/convert.rs index 57db0b430..e08d40ef1 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -1,6 +1,7 @@ //! Traits used to convert between Zend/PHP and Rust types. use crate::{ + args::TypeGroup, boxed::ZBox, error::Result, exception::PhpException, @@ -8,6 +9,76 @@ use crate::{ types::{ZendObject, Zval}, }; +/// Represents PHP union type information returned by `PhpUnion::union_types()`. +/// +/// This enum allows representing both simple union types (`int|string`) and +/// DNF (Disjunctive Normal Form) types (`(Countable&Traversable)|ArrayAccess`). +#[derive(Debug, Clone)] +pub enum PhpUnionTypes { + /// Simple union of primitive or class types. + /// Example: `int|string|null` or `Iterator|ArrayAccess` + Simple(Vec), + + /// DNF type combining unions and intersections (PHP 8.2+). + /// Example: `(Countable&Traversable)|ArrayAccess` + Dnf(Vec), +} + +/// Trait for types that represent PHP union types (e.g., `int|string`). +/// +/// This trait is typically implemented via the `#[derive(PhpUnion)]` macro +/// on enums where each variant holds a single type that maps to a PHP type. +/// +/// # Example +/// +/// ```ignore +/// use ext_php_rs::prelude::*; +/// +/// #[derive(Debug, Clone, PhpUnion)] +/// enum IntOrString { +/// Int(i64), +/// Str(String), +/// } +/// +/// #[php_function] +/// fn accepts_int_or_string(value: IntOrString) -> String { +/// match value { +/// IntOrString::Int(n) => format!("Got int: {n}"), +/// IntOrString::Str(s) => format!("Got string: {s}"), +/// } +/// } +/// ``` +/// +/// This generates PHP: `function accepts_int_or_string(int|string $value): +/// string` +/// +/// # DNF Types (PHP 8.2+) +/// +/// For DNF types that combine unions and intersections, use the +/// `#[php(intersection = ["Interface1", "Interface2"])]` attribute: +/// +/// ```ignore +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::types::ZendObject; +/// +/// #[derive(PhpUnion)] +/// enum MyDnf { +/// #[php(intersection = ["Countable", "Traversable"])] +/// CountableTraversable(ZendObject), +/// #[php(interface = "ArrayAccess")] +/// ArrayAccess(ZendObject), +/// } +/// ``` +/// +/// This generates PHP: `(Countable&Traversable)|ArrayAccess` +pub trait PhpUnion<'a>: FromZval<'a> + IntoZval { + /// Returns the PHP type information for this union. + /// + /// Returns either a simple union of types or a DNF type with intersection + /// groups. + fn union_types() -> PhpUnionTypes; +} + /// Allows zvals to be converted into Rust types in a fallible way. Reciprocal /// of the [`IntoZval`] trait. pub trait FromZval<'a>: Sized { diff --git a/src/embed/mod.rs b/src/embed/mod.rs index d175c3f69..b00326841 100644 --- a/src/embed/mod.rs +++ b/src/embed/mod.rs @@ -163,7 +163,8 @@ impl Embed { ) }; - // Prevent the closure from being dropped here since it was consumed in panic_wrapper + // Prevent the closure from being dropped here since it was consumed in + // panic_wrapper std::mem::forget(func); // This can happen if there is a bailout @@ -294,8 +295,9 @@ mod tests { #[test] fn test_eval_bailout() { Embed::run(|| { - // TODO: For PHP 8.5, this needs to be replaced, as `E_USER_ERROR` is deprecated. - // Currently, this seems to still be the best way to trigger a bailout. + // TODO: For PHP 8.5, this needs to be replaced, as `E_USER_ERROR` is + // deprecated. Currently, this seems to still be the best way + // to trigger a bailout. let result = Embed::eval("trigger_error(\"Fatal error\", E_USER_ERROR);"); assert!(result.is_err()); diff --git a/src/enum_.rs b/src/enum_.rs index 5868a876d..8bcbd6970 100644 --- a/src/enum_.rs +++ b/src/enum_.rs @@ -1,4 +1,5 @@ -//! This module defines the `PhpEnum` trait and related types for Rust enums that are exported to PHP. +//! This module defines the `PhpEnum` trait and related types for Rust enums +//! that are exported to PHP. use std::ptr; use crate::{ @@ -19,7 +20,8 @@ pub trait RegisteredEnum { /// # Errors /// - /// - [`Error::InvalidProperty`] if the enum does not have a case with the given name, an error is returned. + /// - [`Error::InvalidProperty`] if the enum does not have a case with the + /// given name, an error is returned. fn from_name(name: &str) -> Result where Self: Sized; @@ -125,7 +127,8 @@ impl EnumCase { } } -/// Represents the discriminant of an enum case in PHP, which can be either an integer or a string. +/// Represents the discriminant of an enum case in PHP, which can be either an +/// integer or a string. #[derive(Debug, PartialEq, Eq)] pub enum Discriminant { /// An integer discriminant. diff --git a/src/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/lib.rs b/src/lib.rs index b33ef56e0..7864d17ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,8 @@ pub mod zend; /// A module typically glob-imported containing the typically required macros /// and imports. pub mod prelude { + pub use crate::args::TypeGroup; + pub use crate::convert::{PhpUnion, PhpUnionTypes}; pub use crate::builders::ModuleBuilder; #[cfg(any(docs, feature = "closure"))] @@ -58,8 +60,8 @@ pub mod prelude { pub use crate::types::ZendCallable; pub use crate::zend::BailoutGuard; pub use crate::{ - ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, php_interface, - php_module, wrap_constant, wrap_function, zend_fastcall, + PhpUnion, ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, + php_interface, php_module, wrap_constant, wrap_function, zend_fastcall, }; } @@ -75,6 +77,6 @@ pub const PHP_ZTS: bool = cfg!(php_zts); #[cfg(feature = "enum")] pub use ext_php_rs_derive::php_enum; pub use ext_php_rs_derive::{ - ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, php_interface, + PhpUnion, ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, php_interface, php_module, wrap_constant, wrap_function, zend_fastcall, }; diff --git a/src/types/array/conversions/mod.rs b/src/types/array/conversions/mod.rs index fbcc4c7f0..4d4cb4086 100644 --- a/src/types/array/conversions/mod.rs +++ b/src/types/array/conversions/mod.rs @@ -1,8 +1,8 @@ //! Collection type conversions for `ZendHashTable`. //! -//! This module provides conversions between Rust collection types and PHP arrays -//! (represented as `ZendHashTable`). Each collection type has its own module for -//! better organization and maintainability. +//! This module provides conversions between Rust collection types and PHP +//! arrays (represented as `ZendHashTable`). Each collection type has its own +//! module for better organization and maintainability. //! //! ## Supported Collections //! diff --git a/src/types/zval.rs b/src/types/zval.rs index ccfee006f..357e75779 100644 --- a/src/types/zval.rs +++ b/src/types/zval.rs @@ -518,8 +518,8 @@ impl Zval { self.get_type() == DataType::Ptr } - /// Returns true if the zval is a scalar value (integer, float, string, or bool), - /// false otherwise. + /// Returns true if the zval is a scalar value (integer, float, string, or + /// bool), false otherwise. /// /// This is equivalent to PHP's `is_scalar()` function. #[must_use] 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..347acb0de 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,501 @@ 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/src/zend/bailout_guard.rs b/src/zend/bailout_guard.rs index 699e85cff..143ab5983 100644 --- a/src/zend/bailout_guard.rs +++ b/src/zend/bailout_guard.rs @@ -1,9 +1,11 @@ -//! Provides cleanup guarantees for values that need to be dropped even when PHP bailout occurs. +//! Provides cleanup guarantees for values that need to be dropped even when PHP +//! bailout occurs. //! -//! When PHP triggers a bailout (via `exit()`, fatal error, etc.), it uses `longjmp` which -//! bypasses Rust's normal stack unwinding. This means destructors for stack-allocated values -//! won't run. `BailoutGuard` solves this by heap-allocating values and registering cleanup -//! callbacks that run when a bailout is caught. +//! When PHP triggers a bailout (via `exit()`, fatal error, etc.), it uses +//! `longjmp` which bypasses Rust's normal stack unwinding. This means +//! destructors for stack-allocated values won't run. `BailoutGuard` solves this +//! by heap-allocating values and registering cleanup callbacks that run when a +//! bailout is caught. //! //! # Example //! @@ -37,15 +39,16 @@ thread_local! { /// A guard that ensures a value is dropped even if PHP bailout occurs. /// -/// `BailoutGuard` heap-allocates the wrapped value and registers a cleanup callback. -/// If a bailout occurs, the cleanup runs before the bailout is re-triggered. -/// If the guard is dropped normally, the cleanup is cancelled and the value is dropped. +/// `BailoutGuard` heap-allocates the wrapped value and registers a cleanup +/// callback. If a bailout occurs, the cleanup runs before the bailout is +/// re-triggered. If the guard is dropped normally, the cleanup is cancelled and +/// the value is dropped. /// /// # Performance Note /// /// This incurs a heap allocation. Only use for values that absolutely must be -/// cleaned up (file handles, network connections, locks, etc.). For simple values, -/// the overhead isn't worth it. +/// cleaned up (file handles, network connections, locks, etc.). For simple +/// values, the overhead isn't worth it. pub struct BailoutGuard { /// Pointer to the heap-allocated value. Using raw pointer because we need /// to pass it to the cleanup callback. diff --git a/src/zend/mod.rs b/src/zend/mod.rs index a87251013..47671f741 100644 --- a/src/zend/mod.rs +++ b/src/zend/mod.rs @@ -84,8 +84,8 @@ pub fn printf(message: &str) -> Result<()> { /// /// # Errors /// -/// Returns [`crate::error::Error::SapiWriteUnavailable`] if the SAPI's `ub_write` function -/// is not available. +/// Returns [`crate::error::Error::SapiWriteUnavailable`] if the SAPI's +/// `ub_write` function is not available. /// /// # Example /// diff --git a/src/zend/try_catch.rs b/src/zend/try_catch.rs index 58aa39bc4..52d66eb04 100644 --- a/src/zend/try_catch.rs +++ b/src/zend/try_catch.rs @@ -14,8 +14,8 @@ pub(crate) unsafe extern "C" fn panic_wrapper R + UnwindSafe>( ) -> *const c_void { // we try to catch panic here so we correctly shutdown php if it happens // mandatory when we do assert on test as other test would not run correctly - // SAFETY: We read the closure from the pointer and consume it. This is safe because - // the closure is only called once. + // SAFETY: We read the closure from the pointer and consume it. This is safe + // because the closure is only called once. let func = unsafe { std::ptr::read(ctx.cast::()) }; let panic = catch_unwind(func); @@ -79,7 +79,8 @@ fn do_try_catch R + UnwindSafe>(func: F, first: bool) -> Resul } }; - // Prevent the closure from being dropped here since it was consumed in panic_wrapper + // Prevent the closure from being dropped here since it was consumed in + // panic_wrapper std::mem::forget(func); let panic = panic_ptr.cast::>(); 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..d8e01973a --- /dev/null +++ b/tests/build.rs @@ -0,0 +1,90 @@ +//! 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/array/mod.rs b/tests/src/integration/array/mod.rs index 4a928d51c..e7e01d262 100644 --- a/tests/src/integration/array/mod.rs +++ b/tests/src/integration/array/mod.rs @@ -86,7 +86,8 @@ pub fn test_empty_vec() -> Vec { Vec::new() } -/// Test that returning an empty `HashMap` still works (should allocate a new array) +/// Test that returning an empty `HashMap` still works (should allocate a new +/// array) #[php_function] pub fn test_empty_hashmap() -> HashMap { HashMap::new() diff --git a/tests/src/integration/bailout/mod.rs b/tests/src/integration/bailout/mod.rs index 5f14f55d2..c326b9623 100644 --- a/tests/src/integration/bailout/mod.rs +++ b/tests/src/integration/bailout/mod.rs @@ -1,16 +1,19 @@ -//! Test for issue #537 - Rust destructors should be called when PHP bailout occurs. +//! Test for issue #537 - Rust destructors should be called when PHP bailout +//! occurs. //! -//! This test verifies that when PHP triggers a bailout (e.g., via `exit()`), Rust -//! destructors are properly called before the bailout is re-triggered. +//! This test verifies that when PHP triggers a bailout (e.g., via `exit()`), +//! Rust destructors are properly called before the bailout is re-triggered. //! //! There are two mechanisms for ensuring cleanup: //! -//! 1. **Using `try_call`**: When calling PHP code via `try_call`, bailouts are caught -//! internally and the function returns normally, allowing regular Rust destructors to run. +//! 1. **Using `try_call`**: When calling PHP code via `try_call`, bailouts are +//! caught internally and the function returns normally, allowing regular +//! Rust destructors to run. //! -//! 2. **Using `BailoutGuard`**: For values that MUST be cleaned up even if bailout occurs -//! directly (not via `try_call`), wrap them in `BailoutGuard`. This heap-allocates the -//! value and registers a cleanup callback that runs when bailout is caught. +//! 2. **Using `BailoutGuard`**: For values that MUST be cleaned up even if +//! bailout occurs directly (not via `try_call`), wrap them in +//! `BailoutGuard`. This heap-allocates the value and registers a cleanup +//! callback that runs when bailout is caught. use ext_php_rs::prelude::*; use std::sync::atomic::{AtomicU32, Ordering}; @@ -84,8 +87,9 @@ pub fn bailout_test_without_exit() { // No bailout - destructors should run normally when function returns } -/// Test `BailoutGuard` - wrap resources that MUST be cleaned up in `BailoutGuard`. -/// This demonstrates using `BailoutGuard` for guaranteed cleanup even on direct bailout. +/// Test `BailoutGuard` - wrap resources that MUST be cleaned up in +/// `BailoutGuard`. This demonstrates using `BailoutGuard` for guaranteed +/// cleanup even on direct bailout. #[php_function] pub fn bailout_test_with_guard(callback: ext_php_rs::types::ZendCallable) { // Wrap trackers in BailoutGuard - these will be cleaned up even if bailout @@ -111,8 +115,9 @@ fn nested_inner(callback: &ext_php_rs::types::ZendCallable) { let _ = callback.try_call(vec![]); } -/// Test nested calls with `BailoutGuard` - verifies cleanup happens at all nesting levels. -/// This creates guards at multiple call stack levels, then triggers bailout from the innermost. +/// Test nested calls with `BailoutGuard` - verifies cleanup happens at all +/// nesting levels. This creates guards at multiple call stack levels, then +/// triggers bailout from the innermost. #[php_function] pub fn bailout_test_nested(callback: ext_php_rs::types::ZendCallable) { // Outer level guards @@ -122,7 +127,8 @@ pub fn bailout_test_nested(callback: ext_php_rs::types::ZendCallable) { // Call inner function which creates more guards and triggers bailout nested_inner(&callback); - // This code won't be reached due to bailout, but the guards should still be cleaned up + // This code won't be reached due to bailout, but the guards should still be + // cleaned up } /// Test deeply nested calls (3 levels) with `BailoutGuard` diff --git a/tests/src/integration/class/class.php b/tests/src/integration/class/class.php index b4c42eec4..99e6feffe 100644 --- a/tests/src/integration/class/class.php +++ b/tests/src/integration/class/class.php @@ -156,3 +156,70 @@ assert(strpos($output, 'publicNum') !== false, 'var_dump should show public property'); // Private properties should show as ClassName::propertyName in var_dump // Protected properties should show with * prefix + +// ==== 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 ac4a4458e..82e708e18 100644 --- a/tests/src/integration/class/mod.rs +++ b/tests/src/integration/class/mod.rs @@ -322,6 +322,27 @@ impl TestPropertyVisibility { } } +/// 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::() @@ -333,6 +354,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 54501eb89..d899b0af0 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..7228543de --- /dev/null +++ b/tests/src/integration/union/mod.rs @@ -0,0 +1,249 @@ +//! 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; + +// ==== PhpUnion derive macro tests ==== + +/// Rust enum representing a PHP `int|string` union type. +/// Uses the `PhpUnion` derive macro to auto-generate `FromZval`/`IntoZval` and +/// union type info. +#[derive(Debug, Clone, PhpUnion)] +pub enum IntOrString { + Int(i64), + Str(String), +} + +/// Function using `PhpUnion` enum parameter. +/// The `#[php(union_enum)]` attribute tells the macro to use +/// `PhpUnion::union_types()`. +#[php_function] +pub fn test_php_union_enum(#[php(union_enum)] value: IntOrString) -> String { + match value { + IntOrString::Int(n) => format!("int:{n}"), + IntOrString::Str(s) => format!("string:{s}"), + } +} + +/// Rust enum representing a PHP `float|bool` union type. +#[derive(Debug, Clone, PhpUnion)] +pub enum FloatOrBool { + Float(f64), + Bool(bool), +} + +/// Function using `PhpUnion` enum parameter with two different types. +#[php_function] +pub fn test_php_union_float_bool(#[php(union_enum)] value: FloatOrBool) -> String { + match value { + FloatOrBool::Float(f) => format!("float:{f}"), + FloatOrBool::Bool(b) => format!("bool:{b}"), + } +} + +// Note: PhpUnion derive with interface/class types is more complex since object +// references don't implement IntoZval (which takes self by value). For object +// types, users should use the macro-based syntax `#[php(types = "...")]` instead. +// The DNF functionality is tested thoroughly via the macro-based tests below. + +// ==== 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)) + // PhpUnion derive macro tests + .function(wrap_function!(test_php_union_enum)) + .function(wrap_function!(test_php_union_float_bool)); + + // 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..b65808fce --- /dev/null +++ b/tests/src/integration/union/union.php @@ -0,0 +1,314 @@ + $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"; +} + +// ==== PhpUnion derive macro tests ==== +// Tests for the #[derive(PhpUnion)] macro which allows representing PHP union types as Rust enums + +echo "Testing PhpUnion derive macro (int|string)...\n"; + +// Test int|string union via reflection +$func = new ReflectionFunction('test_php_union_enum'); +$params = $func->getParameters(); +assert(count($params) === 1, 'test_php_union_enum should have 1 parameter'); + +$paramType = $params[0]->getType(); +assert($paramType instanceof ReflectionUnionType, 'PhpUnion parameter should be a union type'); + +$typeStr = getTypeString($paramType); +assert($typeStr === 'int|string', "Expected 'int|string', got '$typeStr'"); + +// Call with int - should match IntOrString::Int variant +$result = test_php_union_enum(42); +assert($result === 'int:42', "Expected 'int:42', got '$result'"); + +// Call with string - should match IntOrString::Str variant +$result = test_php_union_enum("hello"); +assert($result === 'string:hello', "Expected 'string:hello', got '$result'"); + +echo "PhpUnion int|string tests passed!\n"; + +echo "Testing PhpUnion derive macro (float|bool)...\n"; + +// Test float|bool union via reflection +$func = new ReflectionFunction('test_php_union_float_bool'); +$params = $func->getParameters(); +$paramType = $params[0]->getType(); +assert($paramType instanceof ReflectionUnionType, 'PhpUnion parameter should be a union type'); + +$typeStr = getTypeString($paramType); +assert($typeStr === 'bool|float', "Expected 'bool|float', got '$typeStr'"); + +// Call with float +$result = test_php_union_float_bool(3.14); +assert($result === 'float:3.14', "Expected 'float:3.14', got '$result'"); + +// Call with bool +$result = test_php_union_float_bool(true); +assert($result === 'bool:true', "Expected 'bool:true', got '$result'"); + +echo "PhpUnion float|bool tests passed!\n"; + +// Note: PhpUnion derive with interface/class types requires types that implement +// both FromZval and IntoZval. For object types, use the macro-based syntax +// #[php(types = "...")] instead. DNF types are tested through macro-based tests. + +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);