diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b6d8e57f7..217c07f69 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -165,8 +165,14 @@ jobs: run: cargo build --release --features closure,anyhow,runtime,observer --workspace # Test - name: Test inline examples - # Macos fails on unstable rust. We skip the inline examples test for now. - if: "!(contains(matrix.os, 'macos') && matrix.rust == 'nightly')" + # macOS test binaries crash at load on macos-15 (chained fixups, ld-prime) + # because shivammathur/setup-php's Homebrew formulas (NTS and -debug-zts) + # do not ship libphp at /lib, leaving PHP runtime + # data symbols unresolved. Build coverage on macOS still runs above. + # Restore this step for macOS once a libphp is available on the runner. + if: "!contains(matrix.os, 'macos')" + env: + EXT_PHP_RS_LINK_LIBPHP: "1" run: cargo test --release --workspace --features closure,anyhow,runtime,observer --no-fail-fast test-embed: name: Test with embed (${{ matrix.label }}) @@ -303,10 +309,3 @@ jobs: -w /workspace \ extphprs/ext-php-rs:musl-${{ matrix.php }}-${{ matrix.phpts[1] }} \ build --release --features closure,anyhow,runtime,observer --workspace - - name: Run tests - run: | - docker run \ - -v $(pwd):/workspace \ - -w /workspace \ - extphprs/ext-php-rs:musl-${{ matrix.php }}-${{ matrix.phpts[1] }} \ - test --workspace --release --features closure,anyhow,runtime,observer --no-fail-fast diff --git a/Cargo.toml b/Cargo.toml index 9d6c4e786..d268f2dfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ smartstring = { version = "1", optional = true } indexmap = { version = "2", optional = true } inventory = "0.3" ext-php-rs-derive = { version = "=0.11.12", path = "./crates/macros" } +ext-php-rs-types = { version = "=0.1.0", path = "./crates/types" } [dev-dependencies] skeptic = "0.13" @@ -61,7 +62,11 @@ runtime = ["ext-php-rs-bindgen/runtime"] static = ["ext-php-rs-bindgen/static"] [workspace] -members = ["crates/macros", "crates/cli", "crates/php-build", "tests"] +members = ["crates/macros", "crates/cli", "crates/php-build", "crates/types", "tests"] + +[workspace.dependencies] +proc-macro2 = "1.0.26" +quote = "1.0.9" [package.metadata.docs.rs] rustdoc-args = ["--cfg", "docs"] diff --git a/README.md b/README.md index 3544f4558..a4be5a3ac 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,11 @@ For more examples read the library - **Lightweight:** You don't have to use the built-in helper macros. It's possible to write your own glue code around your own functions. - **Extensible:** Implement `IntoZval` and `FromZval` for your own custom types, - allowing the type to be used as function parameters and return types. + allowing the type to be used as function parameters and return types. For + PHP type shapes that don't map to a single Rust type (primitive unions, + class unions, intersections, DNF), use `#[php(types = "...")]` on a + parameter or `#[php(returns = "...")]` on a function — see the + [function macro guide](guide/src/macros/function.md#overriding-the-registered-php-type). ## Goals diff --git a/allowed_bindings.rs b/allowed_bindings.rs index 1e91b67ec..a17e089b8 100644 --- a/allowed_bindings.rs +++ b/allowed_bindings.rs @@ -34,12 +34,19 @@ bind! { _sapi_module_struct, _zend_expected_type, _zend_expected_type_Z_EXPECTED_ARRAY, + _zend_expected_type_Z_EXPECTED_ARRAY_OR_NULL, _zend_expected_type_Z_EXPECTED_BOOL, + _zend_expected_type_Z_EXPECTED_BOOL_OR_NULL, _zend_expected_type_Z_EXPECTED_DOUBLE, + _zend_expected_type_Z_EXPECTED_DOUBLE_OR_NULL, _zend_expected_type_Z_EXPECTED_LONG, + _zend_expected_type_Z_EXPECTED_LONG_OR_NULL, _zend_expected_type_Z_EXPECTED_OBJECT, + _zend_expected_type_Z_EXPECTED_OBJECT_OR_NULL, _zend_expected_type_Z_EXPECTED_RESOURCE, + _zend_expected_type_Z_EXPECTED_RESOURCE_OR_NULL, _zend_expected_type_Z_EXPECTED_STRING, + _zend_expected_type_Z_EXPECTED_STRING_OR_NULL, _zend_new_array, _zval_struct__bindgen_ty_1, _zval_struct__bindgen_ty_2, @@ -86,6 +93,7 @@ bind! { zend_class_entry, zend_declare_class_constant, zend_declare_property, + zend_declare_typed_property, zend_do_implement_interface, zend_empty_array, zend_read_property, @@ -138,6 +146,7 @@ bind! { zend_throw_exception_object, zend_type, zend_value, + zend_wrong_parameter_type_error, zend_wrong_parameters_count_error, zval, CONST_CS, @@ -259,6 +268,11 @@ bind! { ts_rsrc_id, _ZEND_TYPE_NAME_BIT, _ZEND_TYPE_LITERAL_NAME_BIT, + _ZEND_TYPE_LIST_BIT, + _ZEND_TYPE_UNION_BIT, + _ZEND_TYPE_INTERSECTION_BIT, + _ZEND_TYPE_ARENA_BIT, + zend_type_list, ZEND_INTERNAL_FUNCTION, ZEND_USER_FUNCTION, ZEND_EVAL_CODE, diff --git a/crates/cli/tests/snapshots/stubs_snapshot__hello_world_stubs.snap b/crates/cli/tests/snapshots/stubs_snapshot__hello_world_stubs.snap index a9c124ea7..566c154d2 100644 --- a/crates/cli/tests/snapshots/stubs_snapshot__hello_world_stubs.snap +++ b/crates/cli/tests/snapshots/stubs_snapshot__hello_world_stubs.snap @@ -11,6 +11,10 @@ namespace { const HELLO_WORLD = 100; + class OtherTestClass { + public function __construct() {} + } + class TestClass { const NEW_CONSTANT_NAME = 5; @@ -58,6 +62,30 @@ namespace { public static function x(): int {} } + /** + * Companion to `flexible_id` showing that the same compile-time parsing + * works for class-side type strings. The literal `\TestClass|\OtherTestClass` + * is parsed at macro-expansion time and resolves the class names against + * PHP's global namespace at extension load. Use a leading `\` for the + * fully qualified name; bare `TestClass` works too because the engine + * places `#[php_class]`-defined structs in the global namespace. + * + * @param \TestClass|\OtherTestClass $_value + * @return void + */ + function accept_class_value(\TestClass|\OtherTestClass $_value): void {} + + /** + * Demonstrates compound PHP type hints. The argument accepts `int|string` + * and the return type registers as `int|string|null`. Both strings are + * parsed at macro-expansion time, so a typo such as `?Foo&Bar` would + * fail at `cargo build` rather than at extension load. + * + * @param int|string $_value + * @return int|string|null + */ + function flexible_id(int|string $_value): int|string|null {} + /** * @param object $z * @return int @@ -73,4 +101,23 @@ namespace { * @return \TestClass */ function new_class(): \TestClass {} + + /** + * @param bool $use_float + * @return int|float + */ + function pick_number(bool $use_float): int|float {} + + /** + * Demonstrates `#[php(returns = "...")]` widening the inferred return + * metadata. The Rust signature returns a concrete `TestClass`, so the + * macro would otherwise register the return type as just `\TestClass`. + * The override widens it to `\TestClass|\OtherTestClass`, which is + * useful when a function returns one specific subtype today but the + * PHP-side contract should leave room for a wider set of legal + * values. Reflection on this function reports the wider union. + * + * @return \TestClass|\OtherTestClass + */ + function produce_test_class_or_other(): \TestClass|\OtherTestClass {} } diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml index 61d323499..cd5bccbcb 100644 --- a/crates/macros/Cargo.toml +++ b/crates/macros/Cargo.toml @@ -18,10 +18,13 @@ proc-macro = true [dependencies] syn = { version = "2.0.100", features = ["full", "extra-traits", "printing"] } darling = "0.23" -quote = "1.0.9" -proc-macro2 = "1.0.26" +quote = { workspace = true } +proc-macro2 = { workspace = true } convert_case = "0.11.0" itertools = "0.14.0" +ext-php-rs-types = { version = "=0.1.0", path = "../types", features = [ + "proc-macro", +] } [lints.rust] missing_docs = "warn" diff --git a/crates/macros/src/class.rs b/crates/macros/src/class.rs index ceb68ba24..ac3ac927f 100644 --- a/crates/macros/src/class.rs +++ b/crates/macros/src/class.rs @@ -252,7 +252,8 @@ fn generate_registered_class_impl( fields.iter().partition(|prop| !prop.is_static()); // Generate instance property descriptors with getter/setter fn pointers. - // Each field property gets a pair of static functions and a PropertyDescriptor entry. + // Each field property gets a pair of static functions and a PropertyDescriptor + // entry. let field_prop_count = instance_props.len(); let field_prop_data: Vec<(TokenStream, TokenStream)> = instance_props .iter() diff --git a/crates/macros/src/function.rs b/crates/macros/src/function.rs index 4ac983862..80e87aa21 100644 --- a/crates/macros/src/function.rs +++ b/crates/macros/src/function.rs @@ -1,10 +1,14 @@ use std::collections::HashMap; +use std::str::FromStr; use darling::{FromAttributes, ToTokens}; +use ext_php_rs_types::PhpType; use proc_macro2::{Ident, Span, TokenStream}; use quote::{format_ident, quote, quote_spanned}; +use syn::punctuated::Punctuated; use syn::spanned::Spanned as _; -use syn::{Expr, FnArg, GenericArgument, ItemFn, PatType, PathArguments, Type, TypePath}; +use syn::token::Comma; +use syn::{Expr, FnArg, GenericArgument, ItemFn, LitStr, PatType, PathArguments, Type, TypePath}; use crate::helpers::get_docs; use crate::parsing::{ @@ -62,15 +66,89 @@ struct PhpFunctionAttribute { rename: PhpRename, defaults: HashMap, optional: Option, + returns: Option, vis: Option, attrs: Vec, } +#[derive(FromAttributes, Default, Debug)] +#[darling(default, attributes(php))] +pub struct PhpArgAttribute { + pub types: Option, +} + +/// Pulls a per-argument `#[php(types = "...")]` override off each `FnArg`, +/// returning a `Vec` aligned with the iteration order so it can be zipped +/// into [`Args::parse_from_fnargs`]. Receivers always yield `None`. +/// +/// Each `#[php(types = "...")]` literal is parsed at expansion time via +/// [`parse_php_type_litstr`]; a parse failure surfaces as a `compile_error!` +/// spanned on the offending literal. +pub fn extract_arg_php_type_overrides<'a>( + inputs: impl Iterator, +) -> Result>> { + let mut overrides = Vec::new(); + for fn_arg in inputs { + match fn_arg { + FnArg::Typed(pat_type) => { + let attr = PhpArgAttribute::from_attributes(&pat_type.attrs)?; + let parsed = match &attr.types { + Some(lit) => Some(parse_php_type_litstr(lit)?), + None => None, + }; + overrides.push(parsed); + } + FnArg::Receiver(_) => overrides.push(None), + } + } + Ok(overrides) +} + +/// Removes the consumed `#[php(...)]` attributes from each typed `FnArg` so +/// the re-emitted `ItemFn` compiles cleanly under rustc. Mirrors the +/// function-level strip already done in [`parser`]. +pub fn strip_per_arg_php_attrs(inputs: &mut Punctuated) { + for fn_arg in inputs.iter_mut() { + if let FnArg::Typed(pat_type) = fn_arg { + pat_type.attrs.retain(|a| !a.path().is_ident("php")); + } + } +} + +/// Parses the `LitStr` passed to `#[php(types = ...)]` / `#[php(returns = +/// ...)]` into a [`PhpType`] at macro-expansion time. +/// +/// The parser is the same one the runtime would call — it lives in the +/// shared `ext-php-rs-types` crate so both this proc-macro and the runtime +/// crate share a single grammar. A parse failure becomes a `compile_error!` +/// spanned on the offending literal, so authors see the diagnostic at +/// `cargo build` instead of `cargo run`. +/// +/// # Errors +/// +/// Returns a [`syn::Error`] spanned on `lit` whenever +/// [`PhpType::from_str`] returns an error. +pub fn parse_php_type_litstr(lit: &LitStr) -> Result { + let value = lit.value(); + PhpType::from_str(&value) + .map_err(|err| syn::Error::new(lit.span(), format!("invalid PHP type {value:?}: {err}"))) +} + pub fn parser(mut input: ItemFn) -> Result { let php_attr = PhpFunctionAttribute::from_attributes(&input.attrs)?; input.attrs.retain(|attr| !attr.path().is_ident("php")); - let args = Args::parse_from_fnargs(input.sig.inputs.iter(), php_attr.defaults)?; + let arg_overrides = extract_arg_php_type_overrides(input.sig.inputs.iter())?; + strip_per_arg_php_attrs(&mut input.sig.inputs); + let returns_override = match &php_attr.returns { + Some(lit) => Some(parse_php_type_litstr(lit)?), + None => None, + }; + + let args = Args::parse_from_fnargs( + input.sig.inputs.iter().zip(arg_overrides), + php_attr.defaults, + )?; if let Some(ReceiverArg { span, .. }) = args.receiver { bail!(span => "Receiver arguments are invalid on PHP functions. See `#[php_impl]`."); } @@ -81,7 +159,14 @@ pub fn parser(mut input: ItemFn) -> Result { .rename .rename(ident_to_php_name(&input.sig.ident), RenameRule::Snake); validate_php_name(&func_name, PhpNameContext::Function, input.sig.ident.span())?; - let func = Function::new(&input.sig, func_name, args, php_attr.optional, docs); + let func = Function::new( + &input.sig, + func_name, + args, + php_attr.optional, + returns_override, + docs, + ); let function_impl = func.php_function_impl(); Ok(quote! { @@ -102,6 +187,11 @@ pub struct Function<'a> { pub output: Option<&'a Type>, /// The first optional argument of the function. pub optional: Option, + /// Optional `#[php(returns = "...")]` override for the registered PHP + /// return type. When set, the macro emits the parsed [`PhpType`] + /// directly via [`quote::ToTokens`] instead of deriving the type from + /// the Rust signature via `IntoZval::TYPE`. + pub returns_override: Option, /// Doc comments for the function. pub docs: Vec, } @@ -140,6 +230,7 @@ impl<'a> Function<'a> { name: String, args: Args<'a>, optional: Option, + returns_override: Option, docs: Vec, ) -> Self { Self { @@ -151,6 +242,7 @@ impl<'a> Function<'a> { syn::ReturnType::Type(_, ty) => Some(&**ty), }, optional, + returns_override, docs, } } @@ -321,6 +413,15 @@ impl<'a> Function<'a> { } fn build_returns(&self, call_type: Option<&CallType>) -> TokenStream { + // `#[php(returns = "...")]` overrides whatever the Rust signature + // would derive. Nullability is encoded inside the parsed `PhpType` + // (e.g. `int|string|null`), so we pass `allow_null=false` here. + if let Some(parsed) = &self.returns_override { + return quote! { + .returns(#parsed, false, false) + }; + } + let Some(output) = self.output.cloned() else { // PHP magic methods __destruct and __clone cannot have return types // (only applies to class methods, not standalone functions) @@ -345,7 +446,7 @@ impl<'a> Function<'a> { { return quote! { .returns( - <&mut ::ext_php_rs::types::ZendClassObject<#class> as ::ext_php_rs::convert::IntoZval>::TYPE, + <&mut ::ext_php_rs::types::ZendClassObject<#class> as ::ext_php_rs::convert::IntoZval>::php_type(), false, <&mut ::ext_php_rs::types::ZendClassObject<#class> as ::ext_php_rs::convert::IntoZval>::NULLABLE, ) @@ -359,7 +460,7 @@ impl<'a> Function<'a> { { return quote! { .returns( - <#class as ::ext_php_rs::convert::IntoZval>::TYPE, + <#class as ::ext_php_rs::convert::IntoZval>::php_type(), false, <#class as ::ext_php_rs::convert::IntoZval>::NULLABLE, ) @@ -368,7 +469,7 @@ impl<'a> Function<'a> { quote! { .returns( - <#output as ::ext_php_rs::convert::IntoZval>::TYPE, + <#output as ::ext_php_rs::convert::IntoZval>::php_type(), false, <#output as ::ext_php_rs::convert::IntoZval>::NULLABLE, ) @@ -891,6 +992,11 @@ pub struct TypedArg<'a> { pub default: Option, pub as_ref: bool, pub variadic: bool, + /// Optional `#[php(types = "...")]` override for the registered PHP type + /// of this argument. When set, the macro emits the parsed [`PhpType`] + /// directly via [`quote::ToTokens`] instead of deriving the type from + /// the Rust signature via `FromZvalMut::TYPE`. + pub php_type_override: Option, } #[derive(Debug)] @@ -901,14 +1007,14 @@ pub struct Args<'a> { impl<'a> Args<'a> { pub fn parse_from_fnargs( - args: impl Iterator, + args: impl Iterator)>, mut defaults: HashMap, ) -> Result { let mut result = Self { receiver: None, typed: vec![], }; - for arg in args { + for (arg, php_type_override) in args { match arg { FnArg::Receiver(receiver) => { if receiver.reference.is_none() { @@ -937,6 +1043,7 @@ impl<'a> Args<'a> { default, as_ref, variadic, + php_type_override, }); } } @@ -1075,12 +1182,6 @@ impl TypedArg<'_> { /// `ext-php-rs`. fn arg_builder(&self) -> TokenStream { let name = ident_to_php_name(self.name); - let ty = self.clean_ty(); - let null = if self.nullable { - Some(quote! { .allow_null() }) - } else { - None - }; let default = self.default.as_ref().map(|val| { let val = expr_to_php_stub(val); quote! { @@ -1093,8 +1194,28 @@ impl TypedArg<'_> { None }; let variadic = self.variadic.then(|| quote! { .is_variadic() }); + + // When `#[php(types = "...")]` is set, the override is the source of + // truth for the PHP type — including nullability. Other modifiers + // (default, as_ref, variadic) are about the argument-passing + // protocol, not the type, so they still apply. + if let Some(parsed) = &self.php_type_override { + return quote! { + ::ext_php_rs::args::Arg::new(#name, #parsed) + #default + #as_ref + #variadic + }; + } + + let ty = self.clean_ty(); + let null = if self.nullable { + Some(quote! { .allow_null() }) + } else { + None + }; quote! { - ::ext_php_rs::args::Arg::new(#name, <#ty as ::ext_php_rs::convert::FromZvalMut>::TYPE) + ::ext_php_rs::args::Arg::new(#name, <#ty as ::ext_php_rs::convert::FromZvalMut>::php_type()) #null #default #as_ref @@ -1361,4 +1482,175 @@ mod tests { let expr: Expr = syn::parse_quote!(Some(42_usize)); assert_eq!(expr_to_php_stub(&expr), "42"); } + + #[test] + fn parse_php_type_litstr_accepts_primitive_union() { + let lit: LitStr = syn::parse_quote!("int|string|null"); + let parsed = parse_php_type_litstr(&lit).expect("primitive union parses"); + assert_eq!(format!("{parsed}"), "int|string|null"); + } + + #[test] + fn parse_php_type_litstr_accepts_class_union() { + let lit: LitStr = syn::parse_quote!("\\Foo|\\Bar"); + let parsed = parse_php_type_litstr(&lit).expect("class union parses"); + assert_eq!(format!("{parsed}"), "\\Foo|\\Bar"); + } + + #[test] + fn parse_php_type_litstr_accepts_intersection() { + let lit: LitStr = syn::parse_quote!("\\Countable&\\Traversable"); + let parsed = parse_php_type_litstr(&lit).expect("intersection parses"); + assert_eq!(format!("{parsed}"), "\\Countable&\\Traversable"); + } + + #[test] + fn parse_php_type_litstr_accepts_dnf() { + let lit: LitStr = syn::parse_quote!("(\\A&\\B)|\\C"); + let parsed = parse_php_type_litstr(&lit).expect("DNF parses"); + assert_eq!(format!("{parsed}"), "(\\A&\\B)|\\C"); + } + + #[test] + fn parse_php_type_litstr_rejects_empty() { + let lit: LitStr = syn::parse_quote!(""); + let err = parse_php_type_litstr(&lit).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("empty"), "unexpected error message: {msg}"); + } + + #[test] + fn parse_php_type_litstr_rejects_double_pipe() { + // The parser rejects `||` because the empty alternative between + // pipes is not a legal PHP type term. + let lit: LitStr = syn::parse_quote!("int||string"); + let err = parse_php_type_litstr(&lit).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("empty term"), + "unexpected error message: {msg}" + ); + } + + #[test] + fn parse_php_type_litstr_rejects_class_nullable_shorthand() { + // `?Foo` was previously accepted by the lightweight syntactic check + // and only failed at extension load. With the parser running at + // expansion time, the rejection now spans the LitStr at compile time. + let lit: LitStr = syn::parse_quote!("?Foo"); + let err = parse_php_type_litstr(&lit).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("class-side nullable"), + "unexpected error message: {msg}" + ); + } + + #[test] + fn parser_strips_per_arg_php_attrs_from_emitted_fn() { + // Regression guard for PR #637: `#[php(types = ...)]` on a parameter + // must be removed from the re-emitted ItemFn so rustc never sees the + // unknown attribute. + let input: ItemFn = syn::parse_quote! { + pub fn foo( + #[php(types = "int|string")] _value: i64, + ) -> i64 { + _value + } + }; + let output = parser(input).expect("parser should succeed").to_string(); + assert!( + !output.contains("# [php"), + "expected #[php(...)] stripped from emitted fn, output: {output}" + ); + } + + #[test] + fn parser_emits_compile_time_phptype_for_typed_override() { + let input: ItemFn = syn::parse_quote! { + pub fn foo( + #[php(types = "int|string")] _value: i64, + ) -> i64 { + _value + } + }; + let output = parser(input).expect("parser should succeed").to_string(); + // No more `from_str` runtime call: the macro emits the parsed + // `PhpType::Union(vec![DataType::Long, DataType::String])` literal. + assert!( + !output.contains("from_str"), + "expected NO runtime from_str call, output: {output}" + ); + assert!( + output.contains("PhpType :: Union"), + "expected literal Union variant, output: {output}" + ); + assert!( + output.contains("DataType :: Long"), + "expected DataType::Long in expansion, output: {output}" + ); + assert!( + output.contains("DataType :: String"), + "expected DataType::String in expansion, output: {output}" + ); + } + + #[test] + fn parser_emits_compile_time_phptype_for_returns_override() { + let input: ItemFn = syn::parse_quote! { + #[php(returns = "int|string|null")] + pub fn foo() -> i64 { 0 } + }; + let output = parser(input).expect("parser should succeed").to_string(); + assert!( + !output.contains("from_str"), + "expected NO runtime from_str call for returns, output: {output}" + ); + assert!( + output.contains("PhpType :: Union"), + "expected literal Union variant in returns, output: {output}" + ); + assert!( + output.contains("DataType :: Null"), + "expected DataType::Null in expansion, output: {output}" + ); + } + + #[test] + fn parser_rejects_invalid_per_arg_litstr() { + // Compile-time grammar rejection — the parser refuses an empty + // term between two pipes, surfaced as a `compile_error!` spanned on + // the LitStr. + let input: ItemFn = syn::parse_quote! { + pub fn foo( + #[php(types = "int||string")] _value: i64, + ) -> i64 { + _value + } + }; + let err = parser(input).unwrap_err(); + assert!( + err.to_string().contains("empty term"), + "unexpected error: {err}", + ); + } + + #[test] + fn parser_rejects_class_nullable_shorthand_at_compile_time() { + // `?Foo` used to pass the syntactic check and panic at extension + // load. Now the parser runs at expansion time, so the diagnostic + // appears at `cargo build`. + let input: ItemFn = syn::parse_quote! { + pub fn foo( + #[php(types = "?Foo")] _value: i64, + ) -> i64 { + _value + } + }; + let err = parser(input).unwrap_err(); + assert!( + err.to_string().contains("class-side nullable"), + "unexpected error: {err}", + ); + } } diff --git a/crates/macros/src/impl_.rs b/crates/macros/src/impl_.rs index 7295bb24e..647c90445 100644 --- a/crates/macros/src/impl_.rs +++ b/crates/macros/src/impl_.rs @@ -3,10 +3,13 @@ use darling::util::Flag; use proc_macro2::TokenStream; use quote::quote; use std::collections::{HashMap, HashSet}; -use syn::{Expr, Ident, ItemImpl}; +use syn::{Expr, Ident, ItemImpl, LitStr}; use crate::constant::PhpConstAttribute; -use crate::function::{Args, CallType, Function, MethodReceiver}; +use crate::function::{ + Args, CallType, Function, MethodReceiver, extract_arg_php_type_overrides, + parse_php_type_litstr, strip_per_arg_php_attrs, +}; use crate::helpers::get_docs; use crate::parsing::{ PhpNameContext, PhpRename, RenameRule, Visibility, ident_to_php_name, validate_php_name, @@ -71,6 +74,9 @@ struct MethodArgs { optional: Option, /// Default values for optional arguments. defaults: HashMap, + /// Optional `#[php(returns = "...")]` override for the registered PHP + /// return type. + returns: Option, /// Visibility of the method (public, protected, private). vis: Visibility, /// Method type. @@ -86,6 +92,7 @@ pub struct PhpFunctionImplAttribute { rename: PhpRename, defaults: HashMap, optional: Option, + returns: Option, vis: Option, attrs: Vec, getter: Flag, @@ -156,6 +163,7 @@ impl MethodArgs { name, optional: attr.optional, defaults: attr.defaults, + returns: attr.returns, vis: attr.vis.unwrap_or(Visibility::Public), ty, is_final, @@ -321,8 +329,25 @@ impl<'a> ParsedImpl<'a> { continue; } - let args = Args::parse_from_fnargs(method.sig.inputs.iter(), opts.defaults)?; - let mut func = Function::new(&method.sig, opts.name, args, opts.optional, docs); + let arg_overrides = extract_arg_php_type_overrides(method.sig.inputs.iter())?; + strip_per_arg_php_attrs(&mut method.sig.inputs); + let returns_override = match &opts.returns { + Some(lit) => Some(parse_php_type_litstr(lit)?), + None => None, + }; + + let args = Args::parse_from_fnargs( + method.sig.inputs.iter().zip(arg_overrides), + opts.defaults, + )?; + let mut func = Function::new( + &method.sig, + opts.name, + args, + opts.optional, + returns_override, + docs, + ); let mut modifiers: HashSet = HashSet::new(); diff --git a/crates/macros/src/interface.rs b/crates/macros/src/interface.rs index a178fc9ca..c8bbc6e44 100644 --- a/crates/macros/src/interface.rs +++ b/crates/macros/src/interface.rs @@ -2,13 +2,17 @@ use std::collections::{HashMap, HashSet}; use crate::class::ClassEntryAttribute; use crate::constant::PhpConstAttribute; -use crate::function::{Args, Function}; +use crate::function::{ + Args, Function, extract_arg_php_type_overrides, parse_php_type_litstr, strip_per_arg_php_attrs, +}; use crate::helpers::{CleanPhpAttr, get_docs}; use darling::FromAttributes; use darling::util::Flag; use proc_macro2::TokenStream; use quote::{ToTokens, format_ident, quote}; -use syn::{Expr, Ident, ItemTrait, Path, TraitItem, TraitItemConst, TraitItemFn, TypeParamBound}; +use syn::{ + Expr, Ident, ItemTrait, LitStr, Path, TraitItem, TraitItemConst, TraitItemFn, TypeParamBound, +}; use crate::impl_::{FnBuilder, MethodModifier}; use crate::parsing::{ @@ -304,6 +308,7 @@ pub struct PhpFunctionInterfaceAttribute { rename: PhpRename, defaults: HashMap, optional: Option, + returns: Option, vis: Option, attrs: Vec, getter: Flag, @@ -327,7 +332,17 @@ fn parse_trait_item_fn( let php_attr = PhpFunctionInterfaceAttribute::from_attributes(&fn_item.attrs)?; fn_item.attrs.clean_php(); - let mut args = Args::parse_from_fnargs(fn_item.sig.inputs.iter(), php_attr.defaults)?; + let arg_overrides = extract_arg_php_type_overrides(fn_item.sig.inputs.iter())?; + strip_per_arg_php_attrs(&mut fn_item.sig.inputs); + let returns_override = match &php_attr.returns { + Some(lit) => Some(parse_php_type_litstr(lit)?), + None => None, + }; + + let mut args = Args::parse_from_fnargs( + fn_item.sig.inputs.iter().zip(arg_overrides), + php_attr.defaults, + )?; let docs = get_docs(&php_attr.attrs)?; @@ -349,7 +364,14 @@ fn parse_trait_item_fn( PhpNameContext::Method, fn_item.sig.ident.span(), )?; - let f = Function::new(&fn_item.sig, method_name, args, php_attr.optional, docs); + let f = Function::new( + &fn_item.sig, + method_name, + args, + php_attr.optional, + returns_override, + docs, + ); if php_attr.constructor.is_present() { Ok(MethodKind::Constructor(f)) diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index aff60bddf..b73ba52f9 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -12,6 +12,7 @@ mod impl_interface; mod interface; mod module; mod parsing; +mod php_union; mod syn_ext; mod zval; @@ -1477,6 +1478,113 @@ fn php_interface_internal(_args: TokenStream2, input: TokenStream2) -> TokenStre /// # fn main() {} /// ``` /// +/// ## Overriding the registered PHP type +/// +/// Rust signatures can express many but not all PHP types. Compound types such +/// as primitive unions (`int|string`), class unions (`\Foo|\Bar`), +/// intersections (`\Countable&\Traversable`) and DNF (`(\A&\B)|\C`) cannot be +/// derived from a single Rust type via the `IntoZval`/`FromZval` trait path. +/// +/// The `#[php(types = "...")]` attribute on a parameter and the +/// `#[php(returns = "...")]` attribute on a function override the registered +/// PHP type metadata. The string is parsed at macro-expansion time by +/// `PhpType::from_str`; the syntax matches the PHP type-hint grammar (with `\` +/// for namespace separators). +/// +/// ```rust,ignore +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::types::Zval; +/// +/// #[php_function] +/// #[php(returns = "int|string|null")] +/// pub fn flexible_id( +/// #[php(types = "int|string")] _id: &Zval, +/// ) -> i64 { +/// 0 +/// } +/// ``` +/// +/// The same attributes accept class names from your `#[php_class]`-defined +/// structs. The string is the canonical PHP type-hint grammar, so unions +/// (`\Foo|\Bar`), intersections (`\Countable&\Traversable`), and DNF +/// (`(\A&\B)|\C`) all work: +/// +/// ```rust,ignore +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::types::Zval; +/// +/// #[php_class] +/// pub struct Foo; +/// +/// #[php_class] +/// pub struct Bar; +/// +/// #[php_function] +/// pub fn accept(#[php(types = "\\Foo|\\Bar")] _value: &Zval) {} +/// +/// #[php_function] +/// #[php(returns = "\\Foo|\\Bar")] +/// pub fn produce() -> Foo { Foo } +/// ``` +/// +/// Use a leading `\` to anchor the class in PHP's global namespace, matching +/// how PHP code spells fully qualified names. Bare names without the leading +/// `\` work too: `\Foo` and `Foo` produce the same registered metadata, +/// because every `#[php_class]`-defined struct is placed in PHP's global +/// namespace by the engine. +/// +/// The override is the source of truth for the PHP type, including nullability +/// — put `null` in the string if the parameter or return should be nullable. +/// The runtime modifiers (`default`, `optional`, variadic, by-reference) are +/// orthogonal to type and still apply. +/// +/// Parsing runs once, at compile time. A parser-rejected string becomes a +/// `compile_error!` spanned on the literal, so `cargo build` surfaces the +/// diagnostic before the extension ever loads. Two shapes are deliberately +/// rejected: +/// +/// - **`?Foo&Bar`**: a leading `?` on an intersection is not legal PHP, and the +/// parser refuses it. The legal nullable form `(Foo&Bar)|null` requires DNF +/// (PHP 8.2+ in user code, 8.3+ on internal `arg_info`; see the version +/// constraint below). +/// - **Class-side nullables (`?Foo`, `\Foo|null`, `\Foo|\Bar|null`, +/// `(\A&\B)|null`)**: the parser refuses these because the class-side +/// variants of `PhpType` cannot carry an inline `null` member today. This is +/// a known asymmetry with the primitive side, which DOES accept `int|null` +/// (since `DataType::Null` is a primitive variant). For a class-side function +/// that may return null today, use a function whose Rust return type is +/// unconditional and pass nullable values through a separate `null` return +/// path managed by the caller; or wait on the parser follow-up that will +/// surface a `parse_with_nullable(&str)` variant. +/// +/// The same attributes work inside `#[php_impl]`: +/// +/// ```rust,ignore +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::types::Zval; +/// +/// #[php_class] +/// pub struct MyClass; +/// +/// #[php_impl] +/// impl MyClass { +/// pub fn __construct() -> Self { Self } +/// +/// pub fn accept( +/// &self, +/// #[php(types = "int|string")] _value: &Zval, +/// ) -> i64 { 1 } +/// +/// #[php(returns = "int|string|null")] +/// pub fn produce(&self) -> i64 { 0 } +/// } +/// ``` +/// +/// Version constraint: intersection and DNF type hints on internal `arg_info` +/// require PHP 8.3 or newer. On 8.1/8.2 the runtime returns +/// `Err(InvalidCString)` from `Arg::as_arg_info` for those shapes; build the +/// test extension on 8.3+ if you need them. +/// /// ## Variadic Functions /// /// Variadic functions can be implemented by specifying the last argument in the @@ -2410,6 +2518,62 @@ 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 lets a Rust enum stand in for a PHP union +/// type on `#[php_function]` and `#[php_impl]` signatures. Each variant must +/// newtype-wrap exactly one field; the inner type must implement `IntoZval` +/// and `FromZval`. The derive emits: +/// +/// - an `impl PhpUnion` whose `union_types()` returns `PhpType::Union(vec![::TYPE, ::TYPE, ...])`; +/// - an `impl IntoZval` whose `set_zval` dispatches on the variant; +/// - an `impl FromZval` whose `from_zval` tries each variant's inner type in +/// declaration order. Order matters when two inner types accept the same PHP +/// value (e.g. `String` and a parsed numeric `String`); list the more +/// specific variant first. +/// +/// V1 only supports newtype variants — unit, named, and multi-field tuple +/// variants are compile errors. Generics on the enum are also rejected. +/// +/// ## Example +/// +/// ```rust,no_run,ignore +/// # #![cfg_attr(windows, feature(abi_vectorcall))] +/// # extern crate ext_php_rs; +/// use ext_php_rs::prelude::*; +/// +/// #[derive(PhpUnion)] +/// pub enum IntOrString { +/// Int(i64), +/// Str(String), +/// } +/// +/// #[php_function] +/// pub fn echo_either(value: IntOrString) -> IntOrString { +/// value +/// } +/// +/// #[php_module] +/// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { +/// module.function(wrap_function!(echo_either)) +/// } +/// # fn main() {} +/// ``` +/// +/// PHP `ReflectionFunction::getParameters()[0]->getType()` reports +/// `int|string` on the parameter, and the same union on the return type. +#[proc_macro_derive(PhpUnion)] +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); + + php_union::parser(input).unwrap_or_else(|e| e.to_compile_error()) +} + /// Defines an `extern` function with the Zend fastcall convention based on /// operating system. /// @@ -2546,6 +2710,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; #[rustversion::attr(nightly, test)] #[allow(dead_code)] @@ -2602,7 +2767,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/php_union.rs b/crates/macros/src/php_union.rs new file mode 100644 index 000000000..4d3d3caea --- /dev/null +++ b/crates/macros/src/php_union.rs @@ -0,0 +1,114 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::spanned::Spanned as _; +use syn::{DeriveInput, Type}; + +use crate::prelude::*; + +pub fn parser(input: DeriveInput) -> Result { + let DeriveInput { + ident, + data, + generics, + .. + } = input; + + if !generics.params.is_empty() { + bail!(generics.span() => "`#[derive(PhpUnion)]` does not support generics yet; remove type or lifetime parameters from the enum"); + } + + let data = match data { + syn::Data::Enum(data) => data, + syn::Data::Struct(_) => { + bail!(ident.span() => "`#[derive(PhpUnion)]` requires an enum; structs map to objects via `#[derive(ZvalConvert)]`") + } + syn::Data::Union(_) => { + bail!(ident.span() => "`#[derive(PhpUnion)]` requires an enum") + } + }; + + if data.variants.is_empty() { + bail!(ident.span() => "`#[derive(PhpUnion)]` requires at least one variant"); + } + + let mut variants: Vec<(syn::Ident, Type)> = Vec::with_capacity(data.variants.len()); + for variant in &data.variants { + let v_ident = variant.ident.clone(); + match &variant.fields { + syn::Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { + let ty = fields.unnamed.first().unwrap().ty.clone(); + variants.push((v_ident, ty)); + } + syn::Fields::Unnamed(fields) => { + bail!(variant.span() => "`#[derive(PhpUnion)]` variant `{}` must wrap exactly one field; found {}", v_ident, fields.unnamed.len()); + } + syn::Fields::Named(_) => { + bail!(variant.span() => "`#[derive(PhpUnion)]` variant `{}` cannot have named fields; rewrite as `{}(T)`", v_ident, v_ident); + } + syn::Fields::Unit => { + bail!(variant.span() => "`#[derive(PhpUnion)]` variant `{}` must wrap a value; unit variants are not supported", v_ident); + } + } + } + + let variant_types: Vec<&Type> = variants.iter().map(|(_, ty)| ty).collect(); + + let into_arms = variants.iter().map(|(v_ident, _)| { + quote! { + Self::#v_ident(val) => val.set_zval(zv, persistent) + } + }); + + let from_arms = variants.iter().map(|(v_ident, ty)| { + quote! { + if let ::std::option::Option::Some(value) = + <#ty as ::ext_php_rs::convert::FromZval>::from_zval(zval) + { + return ::std::option::Option::Some(Self::#v_ident(value)); + } + } + }); + + Ok(quote! { + impl ::ext_php_rs::types::PhpUnion for #ident { + fn union_types() -> ::ext_php_rs::types::PhpType { + ::ext_php_rs::types::PhpType::Union(::std::vec![ + #(<#variant_types as ::ext_php_rs::convert::IntoZval>::TYPE),* + ]) + } + } + + impl ::ext_php_rs::convert::IntoZval for #ident { + const TYPE: ::ext_php_rs::flags::DataType = ::ext_php_rs::flags::DataType::Mixed; + const NULLABLE: bool = false; + + fn php_type() -> ::ext_php_rs::types::PhpType { + ::union_types() + } + + 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_arms,)* + } + } + } + + impl<'_zval> ::ext_php_rs::convert::FromZval<'_zval> for #ident { + const TYPE: ::ext_php_rs::flags::DataType = ::ext_php_rs::flags::DataType::Mixed; + + fn php_type() -> ::ext_php_rs::types::PhpType { + ::union_types() + } + + fn from_zval(zval: &'_zval ::ext_php_rs::types::Zval) -> ::std::option::Option { + #(#from_arms)* + ::std::option::Option::None + } + } + }) +} diff --git a/crates/macros/tests/expand/interface.expanded.rs b/crates/macros/tests/expand/interface.expanded.rs index 9f20bee38..118815603 100644 --- a/crates/macros/tests/expand/interface.expanded.rs +++ b/crates/macros/tests/expand/interface.expanded.rs @@ -43,12 +43,12 @@ impl ::ext_php_rs::class::RegisteredClass for PhpInterfaceMyInterface { .arg( ::ext_php_rs::args::Arg::new( "arg", - ::TYPE, + ::php_type(), ), ) .not_required() .returns( - ::TYPE, + ::php_type(), false, ::NULLABLE, ) @@ -195,12 +195,12 @@ impl ::ext_php_rs::class::RegisteredClass for PhpInterfaceMyInterface2 { .arg( ::ext_php_rs::args::Arg::new( "arg", - ::TYPE, + ::php_type(), ), ) .not_required() .returns( - ::TYPE, + ::php_type(), false, ::NULLABLE, ), @@ -213,7 +213,7 @@ impl ::ext_php_rs::class::RegisteredClass for PhpInterfaceMyInterface2 { ) .not_required() .returns( - ::TYPE, + ::php_type(), false, ::NULLABLE, ), diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml new file mode 100644 index 000000000..28c5227e5 --- /dev/null +++ b/crates/types/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ext-php-rs-types" +description = "Shared PHP type-string parser and AST for ext-php-rs." +repository = "https://github.com/extphprs/ext-php-rs" +homepage = "https://ext-php.rs" +license = "MIT OR Apache-2.0" +version = "0.1.0" +authors = [ + "Pierre Tondereau ", + "Xenira ", + "David Cole ", +] +edition = "2024" + +[dependencies] +proc-macro2 = { workspace = true, optional = true } +quote = { workspace = true, optional = true } + +[features] +default = [] +proc-macro = ["dep:proc-macro2", "dep:quote"] + +[lints.rust] +missing_docs = "warn" diff --git a/crates/types/src/data_type.rs b/crates/types/src/data_type.rs new file mode 100644 index 000000000..4a0218d54 --- /dev/null +++ b/crates/types/src/data_type.rs @@ -0,0 +1,190 @@ +//! [`DataType`] — the value-side enum the parser produces for primitive type +//! names. Lives here, not in the runtime crate, because [`crate::PhpType`] +//! carries it and the parser must construct it. +//! +//! The runtime crate hangs FFI conversion helpers off this enum (e.g. +//! `ext_php_rs::flags::data_type_from_raw`); those depend on PHP's +//! `IS_*` constants and stay there. + +use std::fmt::{self, Display}; + +/// Valid data types for a [`Zval`](https://docs.rs/ext-php-rs/latest/ext_php_rs/types/struct.Zval.html). +#[repr(C, u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub enum DataType { + /// Undefined + Undef, + /// `null` + Null, + /// `false` + False, + /// `true` + True, + /// Integer (the irony) + Long, + /// Floating point number + Double, + /// String + String, + /// Array + Array, + /// Iterable + Iterable, + /// Object + Object(Option<&'static str>), + /// Resource + Resource, + /// Reference + Reference, + /// Callable + Callable, + /// Constant expression + ConstantExpression, + /// Void + #[default] + Void, + /// Mixed + Mixed, + /// Boolean + Bool, + /// Pointer + Ptr, + /// Indirect (internal) + Indirect, +} + +impl Display for DataType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DataType::Undef => write!(f, "Undefined"), + DataType::Null => write!(f, "Null"), + DataType::False => write!(f, "False"), + DataType::True => write!(f, "True"), + DataType::Long => write!(f, "Long"), + DataType::Double => write!(f, "Double"), + DataType::String => write!(f, "String"), + DataType::Array => write!(f, "Array"), + DataType::Object(obj) => write!(f, "{}", obj.as_deref().unwrap_or("Object")), + DataType::Resource => write!(f, "Resource"), + DataType::Reference => write!(f, "Reference"), + DataType::Callable => write!(f, "Callable"), + DataType::ConstantExpression => write!(f, "Constant Expression"), + DataType::Void => write!(f, "Void"), + DataType::Bool => write!(f, "Bool"), + DataType::Mixed => write!(f, "Mixed"), + DataType::Ptr => write!(f, "Pointer"), + DataType::Indirect => write!(f, "Indirect"), + DataType::Iterable => write!(f, "Iterable"), + } + } +} + +#[cfg(feature = "proc-macro")] +impl quote::ToTokens for DataType { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + use quote::quote; + let stream = match self { + DataType::Undef => quote!(::ext_php_rs::flags::DataType::Undef), + DataType::Null => quote!(::ext_php_rs::flags::DataType::Null), + DataType::False => quote!(::ext_php_rs::flags::DataType::False), + DataType::True => quote!(::ext_php_rs::flags::DataType::True), + DataType::Long => quote!(::ext_php_rs::flags::DataType::Long), + DataType::Double => quote!(::ext_php_rs::flags::DataType::Double), + DataType::String => quote!(::ext_php_rs::flags::DataType::String), + DataType::Array => quote!(::ext_php_rs::flags::DataType::Array), + DataType::Iterable => quote!(::ext_php_rs::flags::DataType::Iterable), + DataType::Object(None) => { + quote!(::ext_php_rs::flags::DataType::Object( + ::core::option::Option::None + )) + } + DataType::Object(Some(name)) => { + quote!( + ::ext_php_rs::flags::DataType::Object( + ::core::option::Option::Some(#name) + ) + ) + } + DataType::Resource => quote!(::ext_php_rs::flags::DataType::Resource), + DataType::Reference => quote!(::ext_php_rs::flags::DataType::Reference), + DataType::Callable => quote!(::ext_php_rs::flags::DataType::Callable), + DataType::ConstantExpression => { + quote!(::ext_php_rs::flags::DataType::ConstantExpression) + } + DataType::Void => quote!(::ext_php_rs::flags::DataType::Void), + DataType::Mixed => quote!(::ext_php_rs::flags::DataType::Mixed), + DataType::Bool => quote!(::ext_php_rs::flags::DataType::Bool), + DataType::Ptr => quote!(::ext_php_rs::flags::DataType::Ptr), + DataType::Indirect => quote!(::ext_php_rs::flags::DataType::Indirect), + }; + stream.to_tokens(tokens); + } +} + +#[cfg(all(test, feature = "proc-macro"))] +mod tokens_tests { + use super::DataType; + use quote::quote; + + fn render(value: &T) -> String { + quote!(#value).to_string() + } + + #[test] + fn each_primitive_emits_the_runtime_path() { + let cases: &[(DataType, proc_macro2::TokenStream)] = &[ + (DataType::Long, quote!(::ext_php_rs::flags::DataType::Long)), + (DataType::Null, quote!(::ext_php_rs::flags::DataType::Null)), + ( + DataType::String, + quote!(::ext_php_rs::flags::DataType::String), + ), + (DataType::Bool, quote!(::ext_php_rs::flags::DataType::Bool)), + (DataType::True, quote!(::ext_php_rs::flags::DataType::True)), + ( + DataType::Double, + quote!(::ext_php_rs::flags::DataType::Double), + ), + (DataType::Void, quote!(::ext_php_rs::flags::DataType::Void)), + ( + DataType::Mixed, + quote!(::ext_php_rs::flags::DataType::Mixed), + ), + ( + DataType::Iterable, + quote!(::ext_php_rs::flags::DataType::Iterable), + ), + ( + DataType::Callable, + quote!(::ext_php_rs::flags::DataType::Callable), + ), + ]; + for (dt, expected) in cases { + assert_eq!(render(dt), expected.to_string(), "DataType::{dt:?}"); + } + } + + #[test] + fn object_none_emits_explicit_option_none() { + let dt = DataType::Object(None); + assert_eq!( + render(&dt), + quote!(::ext_php_rs::flags::DataType::Object( + ::core::option::Option::None + )) + .to_string() + ); + } + + #[test] + fn object_some_inlines_static_name() { + let dt = DataType::Object(Some("Foo")); + assert_eq!( + render(&dt), + quote!(::ext_php_rs::flags::DataType::Object( + ::core::option::Option::Some("Foo") + )) + .to_string() + ); + } +} diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs new file mode 100644 index 000000000..3db5b8145 --- /dev/null +++ b/crates/types/src/lib.rs @@ -0,0 +1,24 @@ +//! Shared PHP type-string parser and AST for [`ext-php-rs`]. +//! +//! This crate hosts the type-system primitives that need to be reachable +//! both from the runtime crate ([`ext-php-rs`]) and from the proc-macro +//! crate ([`ext-php-rs-derive`]). Cargo cannot resolve a dependency cycle +//! between those two, so the shared pieces — [`PhpType`], [`DnfTerm`], +//! [`DataType`], [`PhpTypeParseError`], and the [`FromStr`][std::str::FromStr] +//! impl on [`PhpType`] — live in this third crate. +//! +//! When the `proc-macro` feature is enabled, [`PhpType`], [`DnfTerm`], and +//! [`DataType`] also implement [`quote::ToTokens`], so the macro crate can +//! parse a `LitStr` at expansion time and emit a literal value of the parsed +//! shape. The runtime crate keeps the feature off; consumers pay nothing. +//! +//! [`ext-php-rs`]: https://crates.io/crates/ext-php-rs +//! [`ext-php-rs-derive`]: https://crates.io/crates/ext-php-rs-derive + +#![cfg_attr(docsrs, feature(doc_cfg))] + +mod data_type; +mod php_type; + +pub use data_type::DataType; +pub use php_type::{DnfTerm, PhpType, PhpTypeParseError}; diff --git a/crates/types/src/php_type.rs b/crates/types/src/php_type.rs new file mode 100644 index 000000000..cd5b87679 --- /dev/null +++ b/crates/types/src/php_type.rs @@ -0,0 +1,1487 @@ +//! PHP argument and return type expressions. +//! +//! [`PhpType`] is the single vocabulary used by `ext-php-rs` to describe +//! every shape of PHP type declaration that the crate supports: +//! [`PhpType::Simple`], primitive [`PhpType::Union`], class +//! [`PhpType::ClassUnion`], class [`PhpType::Intersection`] (PHP 8.1+), and +//! the disjunctive normal form [`PhpType::Dnf`] (PHP 8.2+). + +use std::fmt; +use std::str::FromStr; + +use crate::DataType; + +/// One disjunct of a [`PhpType::Dnf`] type. PHP 8.2+. +/// +/// PHP's DNF grammar is a top-level union whose alternatives may themselves +/// be intersection groups, e.g. `(A&B)|C`. Each [`DnfTerm`] is one alternative +/// on the union side: either a single class name (the `C`) or an intersection +/// group (the `A&B`). +/// +/// `Intersection` always carries 2 or more members. A single-element group is +/// rejected by the FFI emission layer; callers should use [`DnfTerm::Single`] +/// for one-class disjuncts. The future type-string parser canonicalises this +/// shape automatically. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DnfTerm { + /// A single class name, e.g. the `C` in `(A&B)|C`. Class names must be + /// non-empty and contain no interior NUL bytes. + Single(String), + /// An intersection group of class/interface names, e.g. the `A&B` in + /// `(A&B)|C`. Always carries 2 or more members; one-element groups are + /// rejected at the FFI emission layer (use [`DnfTerm::Single`] instead). + Intersection(Vec), +} + +/// A PHP type expression as used in argument or return position. +/// +/// `Simple` covers the long-standing single-type form (`int`, `string`, +/// `Foo`, ...). `Union` covers a primitive union such as `int|string`. +/// `ClassUnion` covers a union of class names such as `Foo|Bar`. +/// `Intersection` covers `Countable&Traversable`. `Dnf` covers +/// `(A&B)|C` and its nullable form `(A&B)|null`. +/// +/// A `Union` carrying fewer than two members is technically constructable but +/// semantically equivalent to (or weaker than) a [`PhpType::Simple`]; callers +/// should prefer `Simple` for the single-type case. The runtime does not +/// auto-collapse unions: collapsing is the parser's job in a later step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PhpType { + /// A single type, e.g. `int`, `string`, `Foo`. + Simple(DataType), + /// A union of primitive types, e.g. `int|string`. + /// + /// Including [`DataType::Null`] as a member produces a nullable union + /// (`int|string|null`). The same shape can be expressed by combining a + /// non-null `Union` with `Arg::allow_null`; both forms emit identical + /// bits because `MAY_BE_NULL` and `_ZEND_TYPE_NULLABLE_BIT` share the + /// same value (see `Zend/zend_types.h:148` in php-src). Pick whichever + /// reads best at the call site. + Union(Vec), + /// A union of class names, e.g. `Foo|Bar`. Each entry must be a valid + /// PHP class name (no NUL bytes). + /// + /// A single-element vec is accepted but degenerate: prefer + /// `Simple(DataType::Object(Some(name)))` for the single-class case. + /// + /// Mixing primitives and classes (e.g. `int|Foo`) is not expressible + /// here; class-side DNF such as `(A&B)|C` lives in [`PhpType::Dnf`]. + /// + /// Nullability flows through `Arg::allow_null`; PHP's `?Foo|Bar` + /// shorthand is not legal syntax (the engine rejects `?` on a union), + /// so the rendered stub spells nullables as `Foo|Bar|null`. + ClassUnion(Vec), + /// An intersection of class/interface names, e.g. `Countable&Traversable`. + /// A value satisfies the type only when it is an instance of every named + /// class or interface. Each entry must be a valid PHP class name (no NUL + /// bytes). + /// + /// A single-element vec is accepted but degenerate: prefer + /// `Simple(DataType::Object(Some(name)))` for the single-class case. + /// + /// Pairing this variant with `Arg::allow_null` is rejected by the + /// FFI emission layer. The legal nullable form is the DNF + /// `(Foo&Bar)|null`; build a [`PhpType::Dnf`] for that case. + Intersection(Vec), + /// Disjunctive Normal Form: a top-level union whose alternatives may + /// themselves be intersection groups, e.g. `(A&B)|C`. PHP 8.2+. + /// + /// Examples: + /// - `(A&B)|C` produces + /// `Dnf(vec![DnfTerm::Intersection(["A","B"]), DnfTerm::Single("C")])`. + /// - `(A&B)|null` produces + /// `Dnf(vec![DnfTerm::Intersection(["A","B"])])` with + /// `Arg::allow_null` on the arg. + /// + /// Nullability is carried via `allow_null`, never as a stringly-typed + /// `DnfTerm::Single("null")` term — the same canonicalisation rule the + /// other compound variants follow. Mixing primitives with class terms + /// (e.g. `(A&B)|int`) is intentionally not modelled here; if demand + /// surfaces, [`DnfTerm`] can grow a third variant in a follow-up. + /// + /// Validation (see the FFI emission layer): empty `terms` is rejected; + /// `terms.len() == 1` is degenerate (use [`PhpType::Simple`] or + /// [`PhpType::Intersection`]); each + /// [`DnfTerm::Intersection`] must carry 2 or more members. + Dnf(Vec), +} + +impl From for PhpType { + fn from(dt: DataType) -> Self { + Self::Simple(dt) + } +} + +impl fmt::Display for PhpType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Simple(dt) => write_php_primitive_or_class(*dt, f), + Self::Union(members) => { + let mut first = true; + for dt in members { + if !first { + f.write_str("|")?; + } + write_php_primitive_or_class(*dt, f)?; + first = false; + } + Ok(()) + } + Self::ClassUnion(names) => write_pipe_joined_classes(names, f), + Self::Intersection(names) => write_amp_joined_classes(names, f), + Self::Dnf(terms) => { + let mut first = true; + for term in terms { + if !first { + f.write_str("|")?; + } + fmt::Display::fmt(term, f)?; + first = false; + } + Ok(()) + } + } + } +} + +impl fmt::Display for DnfTerm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Single(name) => write_class_name(name, f), + Self::Intersection(names) => { + f.write_str("(")?; + write_amp_joined_classes(names, f)?; + f.write_str(")") + } + } + } +} + +fn write_php_primitive_or_class(dt: DataType, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match dt { + DataType::Bool => f.write_str("bool"), + DataType::True => f.write_str("true"), + DataType::False => f.write_str("false"), + DataType::Long => f.write_str("int"), + DataType::Double => f.write_str("float"), + DataType::String => f.write_str("string"), + DataType::Array => f.write_str("array"), + DataType::Object(Some(name)) => write_class_name(name, f), + DataType::Object(None) => f.write_str("object"), + DataType::Resource => f.write_str("resource"), + DataType::Callable => f.write_str("callable"), + DataType::Iterable => f.write_str("iterable"), + DataType::Void => f.write_str("void"), + DataType::Null => f.write_str("null"), + // `Mixed` plus the variants without a syntactic PHP type form + // (`Undef`, `Reference`, `ConstantExpression`, `Ptr`, `Indirect`) + // all render as `mixed`, matching `datatype_to_phpdoc` in + // `src/describe/stub.rs` of the runtime crate. + DataType::Mixed + | DataType::Undef + | DataType::Reference + | DataType::ConstantExpression + | DataType::Ptr + | DataType::Indirect => f.write_str("mixed"), + } +} + +fn write_class_name(name: &str, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if name.starts_with('\\') { + f.write_str(name) + } else { + f.write_str("\\")?; + f.write_str(name) + } +} + +fn write_pipe_joined_classes(names: &[String], f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut first = true; + for name in names { + if !first { + f.write_str("|")?; + } + write_class_name(name, f)?; + first = false; + } + Ok(()) +} + +fn write_amp_joined_classes(names: &[String], f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut first = true; + for name in names { + if !first { + f.write_str("&")?; + } + write_class_name(name, f)?; + first = false; + } + Ok(()) +} + +const _: () = { + assert!(core::mem::size_of::() <= 32); +}; + +/// Error produced by [`PhpType::from_str`]. +/// +/// The parser surfaces every failure mode that the runtime crate can check +/// without round-tripping through `zend_compile.c`. Variants carry byte +/// positions in the input where useful so callers (especially the +/// `#[php(types = "...")]` proc-macro) can underline the offending span. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum PhpTypeParseError { + /// Input was empty or whitespace-only. + Empty, + /// A `|`-separated alternative was empty (e.g. leading or trailing pipe, + /// or two pipes in a row). + EmptyTerm { + /// Byte position of the empty alternative in the original input. + pos: usize, + }, + /// A `(` was opened without a matching `)`, or vice versa. + UnbalancedParens { + /// Byte position of the offending parenthesis. + pos: usize, + }, + /// An unexpected character was encountered (control byte, stray comma, + /// nested `(`, etc.). + UnexpectedChar { + /// The offending character. + ch: char, + /// Byte position of the offending character. + pos: usize, + }, + /// A `(` appeared inside another `(` group: DNF only allows one level. + NestedGroups { + /// Byte position of the inner `(`. + pos: usize, + }, + /// A `|` appeared inside an intersection group: PHP rejects unions + /// nested inside intersections (`A&(B|C)` is illegal). + UnionInIntersection { + /// Byte position of the offending `|`. + pos: usize, + }, + /// A bare `&` appeared outside a `( ... )` group at union level: PHP's + /// grammar refuses `A&B|C` because `intersection_type` is not a + /// `union_type_element` without parens. + NakedAmpInUnion { + /// Byte position of the offending `&`. + pos: usize, + }, + /// A `?` shorthand was applied to a compound type (`?int|string`, + /// `?A&B`, `?(A&B)`). `?` is only legal on a single primitive or class. + NullableCompound { + /// Byte position of the offending `?`. + pos: usize, + }, + /// A `( ... )` group held fewer than two members. PHP requires at least + /// `(A&B)` inside parens; `(A)` is a grammar error. + IntersectionTooSmall { + /// Byte position of the offending `(`. + pos: usize, + }, + /// A class name was empty or contained an interior NUL byte (the runtime + /// would later turn that into `Error::InvalidCString`; the parser catches + /// it earlier). + InvalidClassName { + /// The offending class name. + name: String, + }, + /// A keyword `static`, `never`, `self`, or `parent` appeared. ext-php-rs + /// cannot register internal arg-info for these — they're context types + /// the engine resolves at the call site. + UnsupportedKeyword { + /// The offending keyword. + name: String, + }, + /// The same primitive or class name appeared twice in a union or + /// intersection. PHP rejects duplicates with + /// "Duplicate type %s is redundant". + DuplicateMember { + /// The duplicated member, rendered in PHP syntax. + name: String, + }, + /// A union mixed primitive types with class names (`int|Foo`). The + /// runtime [`PhpType`] variants do not model this mixing — see the + /// note on [`PhpType::Dnf`]. + MixedPrimitiveAndClass, + /// The input describes a class-side type combined with `null` + /// (`?Foo`, `Foo|null`, `Foo|Bar|null`, `(A&B)|null`). The runtime + /// [`PhpType`] does not carry nullability for class-side variants; + /// callers should parse the non-null form and chain `Arg::allow_null` + /// on the resulting `Arg`. + ClassNullableNotRepresentable, + /// A primitive name appeared inside an intersection. PHP rejects + /// `int&string` and similar shapes at compile time. + PrimitiveInIntersection { + /// The offending primitive name. + name: String, + }, + /// A primitive name appeared inside a class-only context (multi-class + /// union or DNF group). The variants `ClassUnion`/`Dnf` only carry + /// class names; mixing primitives is rejected at construction. + PrimitiveInClassUnion { + /// The offending primitive name. + name: String, + }, +} + +impl fmt::Display for PhpTypeParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "empty type string"), + Self::EmptyTerm { pos } => write!(f, "empty term at position {pos}"), + Self::UnbalancedParens { pos } => { + write!(f, "unbalanced parenthesis at position {pos}") + } + Self::UnexpectedChar { ch, pos } => { + write!(f, "unexpected character {ch:?} at position {pos}") + } + Self::NestedGroups { pos } => { + write!(f, "nested `(` groups not allowed at position {pos}") + } + Self::UnionInIntersection { pos } => write!( + f, + "union inside intersection at position {pos}: intersections cannot contain unions" + ), + Self::NakedAmpInUnion { pos } => write!( + f, + "bare `&` at union level (position {pos}): use parentheses, e.g. `(A&B)|C`" + ), + Self::NullableCompound { pos } => write!( + f, + "`?` shorthand at position {pos} can only apply to a single type" + ), + Self::IntersectionTooSmall { pos } => write!( + f, + "intersection group at position {pos} must contain at least two class names" + ), + Self::InvalidClassName { name } => { + write!(f, "invalid class name {name:?} (empty or contains NUL)") + } + Self::UnsupportedKeyword { name } => write!( + f, + "keyword {name:?} is not supported in ext-php-rs argument and return types" + ), + Self::DuplicateMember { name } => write!(f, "duplicate type {name:?}"), + Self::MixedPrimitiveAndClass => write!( + f, + "primitive types and class names cannot be mixed in a union" + ), + Self::ClassNullableNotRepresentable => write!( + f, + "class-side nullable type cannot be represented as a single PhpType; \ + parse the non-null form and chain `Arg::allow_null()` on the resulting Arg" + ), + Self::PrimitiveInIntersection { name } => { + write!(f, "primitive {name:?} cannot appear in an intersection") + } + Self::PrimitiveInClassUnion { name } => write!( + f, + "primitive {name:?} cannot appear in a class-only union or DNF term" + ), + } + } +} + +impl std::error::Error for PhpTypeParseError {} + +impl FromStr for PhpType { + type Err = PhpTypeParseError; + + fn from_str(s: &str) -> Result { + parse(s) + } +} + +fn parse(s: &str) -> Result { + let trimmed = s.trim(); + if trimmed.is_empty() { + return Err(PhpTypeParseError::Empty); + } + + validate_balanced_parens(s)?; + + let (nullable, body, body_offset) = strip_nullable_prefix(s, trimmed); + + if has_top_level_char(body, '|') { + if nullable { + return Err(PhpTypeParseError::NullableCompound { pos: 0 }); + } + return parse_union(body, body_offset); + } + + if has_top_level_char(body, '&') { + if nullable { + return Err(PhpTypeParseError::NullableCompound { pos: 0 }); + } + return parse_bare_intersection(body, body_offset); + } + + if body.starts_with('(') { + return Err(PhpTypeParseError::IntersectionTooSmall { pos: body_offset }); + } + + let single = parse_atom(body)?; + match single { + Atom::Primitive(dt) if nullable => Ok(PhpType::Union(vec![dt, DataType::Null])), + Atom::Primitive(dt) => Ok(PhpType::Simple(dt)), + Atom::Class(_) if nullable => Err(PhpTypeParseError::ClassNullableNotRepresentable), + Atom::Class(name) => Ok(PhpType::ClassUnion(vec![name])), + } +} + +fn validate_balanced_parens(s: &str) -> Result<(), PhpTypeParseError> { + let mut depth: usize = 0; + let mut last_open: Option = None; + for (i, ch) in s.char_indices() { + match ch { + '(' => { + depth += 1; + last_open = Some(i); + } + ')' => { + if depth == 0 { + return Err(PhpTypeParseError::UnbalancedParens { pos: i }); + } + depth -= 1; + } + _ => {} + } + } + if depth != 0 { + return Err(PhpTypeParseError::UnbalancedParens { + pos: last_open.unwrap_or(0), + }); + } + Ok(()) +} + +fn has_top_level_char(body: &str, target: char) -> bool { + let mut depth = 0usize; + for ch in body.chars() { + match ch { + '(' => depth += 1, + ')' if depth > 0 => depth -= 1, + c if c == target && depth == 0 => return true, + _ => {} + } + } + false +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Atom { + Primitive(DataType), + Class(String), +} + +fn strip_nullable_prefix<'a>(original: &'a str, trimmed: &'a str) -> (bool, &'a str, usize) { + let leading_ws = original.len() - original.trim_start().len(); + if let Some(rest) = trimmed.strip_prefix('?') { + (true, rest.trim_start(), leading_ws + 1) + } else { + (false, trimmed, leading_ws) + } +} + +fn parse_atom(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(PhpTypeParseError::EmptyTerm { pos: 0 }); + } + reject_structural_chars(trimmed)?; + reject_unsupported_keyword(trimmed)?; + if let Some(dt) = primitive_from_name(trimmed) { + return Ok(Atom::Primitive(dt)); + } + let class = normalise_class_name(trimmed)?; + Ok(Atom::Class(class)) +} + +fn reject_structural_chars(name: &str) -> Result<(), PhpTypeParseError> { + for (i, ch) in name.char_indices() { + match ch { + '(' | ')' | '|' | '&' | '?' | ' ' | '\t' | '\n' | '\r' => { + return Err(PhpTypeParseError::UnexpectedChar { ch, pos: i }); + } + _ => {} + } + } + Ok(()) +} + +fn parse_union(body: &str, body_offset: usize) -> Result { + let mut alts: Vec<(Alt, usize)> = Vec::new(); + for piece in split_top_level_pipes(body) { + let span_start = body_offset + piece.start; + let raw = &body[piece.start..piece.end]; + if raw.trim().is_empty() { + return Err(PhpTypeParseError::EmptyTerm { pos: span_start }); + } + alts.push((parse_alt(raw, span_start)?, span_start)); + } + + let has_group = alts.iter().any(|(a, _)| matches!(a, Alt::Group(_))); + let has_class = alts + .iter() + .any(|(a, _)| matches!(a, Alt::Atom(Atom::Class(_)) | Alt::Group(_))); + let has_null = alts + .iter() + .any(|(a, _)| matches!(a, Alt::Atom(Atom::Primitive(DataType::Null)))); + let has_non_null_primitive = alts.iter().any(|(a, _)| { + matches!( + a, + Alt::Atom(Atom::Primitive(dt)) if !matches!(dt, DataType::Null) + ) + }); + + if has_class && has_null { + return Err(PhpTypeParseError::ClassNullableNotRepresentable); + } + if has_class && has_non_null_primitive { + return Err(PhpTypeParseError::MixedPrimitiveAndClass); + } + + if has_group { + let mut terms: Vec = Vec::with_capacity(alts.len()); + for (alt, _) in alts { + terms.push(match alt { + Alt::Group(names) => DnfTerm::Intersection(names), + Alt::Atom(Atom::Class(name)) => DnfTerm::Single(name), + Alt::Atom(Atom::Primitive(_)) => { + unreachable!("guarded above by has_class && has_*_primitive checks") + } + }); + } + check_no_duplicate_in_dnf(&terms)?; + return Ok(PhpType::Dnf(terms)); + } + + if !has_class { + let members: Vec = alts + .into_iter() + .map(|(alt, _)| match alt { + Alt::Atom(Atom::Primitive(dt)) => dt, + _ => unreachable!("class-free path"), + }) + .collect(); + check_no_duplicate_data_types(&members)?; + return Ok(PhpType::Union(members)); + } + + let names: Vec = alts + .into_iter() + .map(|(alt, _)| match alt { + Alt::Atom(Atom::Class(name)) => name, + _ => unreachable!("primitive-free path"), + }) + .collect(); + check_no_duplicate_strings(&names)?; + Ok(PhpType::ClassUnion(names)) +} + +fn check_no_duplicate_data_types(members: &[DataType]) -> Result<(), PhpTypeParseError> { + for (i, a) in members.iter().enumerate() { + for b in &members[..i] { + if a == b { + return Err(PhpTypeParseError::DuplicateMember { + name: format!("{a}"), + }); + } + } + } + Ok(()) +} + +fn check_no_duplicate_strings(names: &[String]) -> Result<(), PhpTypeParseError> { + for (i, a) in names.iter().enumerate() { + for b in &names[..i] { + if a == b { + return Err(PhpTypeParseError::DuplicateMember { name: a.clone() }); + } + } + } + Ok(()) +} + +fn check_no_duplicate_in_dnf(terms: &[DnfTerm]) -> Result<(), PhpTypeParseError> { + for (i, a) in terms.iter().enumerate() { + for b in &terms[..i] { + if a == b { + let name = match a { + DnfTerm::Single(s) => s.clone(), + DnfTerm::Intersection(parts) => format!("({})", parts.join("&")), + }; + return Err(PhpTypeParseError::DuplicateMember { name }); + } + } + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Alt { + Atom(Atom), + Group(Vec), +} + +fn parse_alt(raw: &str, span_start: usize) -> Result { + let trimmed = raw.trim(); + if trimmed.starts_with('(') { + let leading = raw.len() - raw.trim_start().len(); + let group_start = span_start + leading; + return parse_group(trimmed, group_start).map(Alt::Group); + } + if trimmed.contains('&') { + let amp_pos = raw.find('&').map_or(span_start, |i| span_start + i); + return Err(PhpTypeParseError::NakedAmpInUnion { pos: amp_pos }); + } + parse_atom(trimmed).map(Alt::Atom) +} + +fn parse_group(raw: &str, group_start: usize) -> Result, PhpTypeParseError> { + debug_assert!(raw.starts_with('(')); + let inner_end = match raw.rfind(')') { + Some(i) if i > 0 => i, + _ => { + return Err(PhpTypeParseError::UnbalancedParens { pos: group_start }); + } + }; + let after_close = raw[inner_end + 1..].trim(); + if !after_close.is_empty() { + return Err(PhpTypeParseError::UnexpectedChar { + ch: after_close.chars().next().unwrap_or(')'), + pos: group_start + inner_end + 1, + }); + } + let inner = &raw[1..inner_end]; + let inner_offset = group_start + 1; + if inner.contains('(') { + return Err(PhpTypeParseError::NestedGroups { + pos: inner_offset + inner.find('(').unwrap_or(0), + }); + } + if has_top_level_char(inner, '|') { + let pipe_pos = inner.find('|').map_or(inner_offset, |i| inner_offset + i); + return Err(PhpTypeParseError::UnionInIntersection { pos: pipe_pos }); + } + + let pieces = split_top_level_amps(inner); + if pieces.len() < 2 { + return Err(PhpTypeParseError::IntersectionTooSmall { pos: group_start }); + } + let mut names: Vec = Vec::with_capacity(pieces.len()); + for piece in pieces { + let span_start = inner_offset + piece.start; + let part = &inner[piece.start..piece.end]; + if part.trim().is_empty() { + return Err(PhpTypeParseError::EmptyTerm { pos: span_start }); + } + match parse_atom(part)? { + Atom::Class(name) => names.push(name), + Atom::Primitive(dt) => { + return Err(PhpTypeParseError::PrimitiveInIntersection { + name: format!("{dt}"), + }); + } + } + } + Ok(names) +} + +fn parse_bare_intersection(body: &str, body_offset: usize) -> Result { + let mut names: Vec = Vec::new(); + for piece in split_top_level_amps(body) { + let span_start = body_offset + piece.start; + let raw = &body[piece.start..piece.end]; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(PhpTypeParseError::EmptyTerm { pos: span_start }); + } + if trimmed.starts_with('(') { + // `A&(...)` — intersections cannot contain a paren group at all. + // The inner shape is a union (`A&(B|C)`) or another intersection + // (`A&(B&C)`); both are illegal in PHP type hints. + let leading_ws = raw.len() - raw.trim_start().len(); + return Err(PhpTypeParseError::UnionInIntersection { + pos: span_start + leading_ws, + }); + } + match parse_atom(raw)? { + Atom::Class(name) => names.push(name), + Atom::Primitive(dt) => { + return Err(PhpTypeParseError::PrimitiveInIntersection { + name: format!("{dt}"), + }); + } + } + } + check_no_duplicate_strings(&names)?; + Ok(PhpType::Intersection(names)) +} + +fn split_top_level_amps(body: &str) -> Vec { + let mut pieces = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + for (i, ch) in body.char_indices() { + match ch { + '(' => depth += 1, + ')' if depth > 0 => depth -= 1, + '&' if depth == 0 => { + pieces.push(Piece { start, end: i }); + start = i + 1; + } + _ => {} + } + } + pieces.push(Piece { + start, + end: body.len(), + }); + pieces +} + +#[derive(Debug, Clone, Copy)] +struct Piece { + start: usize, + end: usize, +} + +fn split_top_level_pipes(body: &str) -> Vec { + let mut pieces = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + for (i, ch) in body.char_indices() { + match ch { + '(' => depth += 1, + ')' if depth > 0 => depth -= 1, + '|' if depth == 0 => { + pieces.push(Piece { start, end: i }); + start = i + 1; + } + _ => {} + } + } + pieces.push(Piece { + start, + end: body.len(), + }); + pieces +} + +fn reject_unsupported_keyword(name: &str) -> Result<(), PhpTypeParseError> { + let lowered = name.to_ascii_lowercase(); + match lowered.as_str() { + "static" | "never" | "self" | "parent" => Err(PhpTypeParseError::UnsupportedKeyword { + name: name.to_owned(), + }), + _ => Ok(()), + } +} + +fn normalise_class_name(raw: &str) -> Result { + let stripped = raw.strip_prefix('\\').unwrap_or(raw); + if stripped.is_empty() || stripped.contains('\0') { + return Err(PhpTypeParseError::InvalidClassName { + name: raw.to_owned(), + }); + } + Ok(stripped.to_owned()) +} + +fn primitive_from_name(name: &str) -> Option { + let lowered = name.to_ascii_lowercase(); + Some(match lowered.as_str() { + "int" => DataType::Long, + "float" => DataType::Double, + "bool" => DataType::Bool, + "true" => DataType::True, + "false" => DataType::False, + "string" => DataType::String, + "array" => DataType::Array, + "object" => DataType::Object(None), + "callable" => DataType::Callable, + "iterable" => DataType::Iterable, + "resource" => DataType::Resource, + "mixed" => DataType::Mixed, + "void" => DataType::Void, + "null" => DataType::Null, + _ => return None, + }) +} + +#[cfg(feature = "proc-macro")] +impl quote::ToTokens for DnfTerm { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + use quote::quote; + let stream = match self { + Self::Single(name) => { + let name_lit = name.as_str(); + quote!(::ext_php_rs::types::DnfTerm::Single( + ::std::string::String::from(#name_lit) + )) + } + Self::Intersection(names) => { + let literals = names.iter().map(String::as_str); + quote!(::ext_php_rs::types::DnfTerm::Intersection( + ::std::vec![ #( ::std::string::String::from(#literals) ),* ] + )) + } + }; + stream.to_tokens(tokens); + } +} + +#[cfg(feature = "proc-macro")] +impl quote::ToTokens for PhpType { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + use quote::quote; + let stream = match self { + Self::Simple(dt) => quote!(::ext_php_rs::types::PhpType::Simple(#dt)), + Self::Union(members) => { + quote!(::ext_php_rs::types::PhpType::Union( + ::std::vec![ #( #members ),* ] + )) + } + Self::ClassUnion(names) => { + let literals = names.iter().map(String::as_str); + quote!(::ext_php_rs::types::PhpType::ClassUnion( + ::std::vec![ #( ::std::string::String::from(#literals) ),* ] + )) + } + Self::Intersection(names) => { + let literals = names.iter().map(String::as_str); + quote!(::ext_php_rs::types::PhpType::Intersection( + ::std::vec![ #( ::std::string::String::from(#literals) ),* ] + )) + } + Self::Dnf(terms) => { + quote!(::ext_php_rs::types::PhpType::Dnf( + ::std::vec![ #( #terms ),* ] + )) + } + }; + stream.to_tokens(tokens); + } +} + +#[cfg(all(test, feature = "proc-macro"))] +mod tokens_tests { + use super::{DataType, DnfTerm, PhpType}; + use quote::quote; + + fn render(value: &T) -> String { + quote!(#value).to_string() + } + + #[test] + fn simple_int_emits_runtime_path() { + let ty: PhpType = "int".parse().unwrap(); + assert_eq!( + render(&ty), + quote!(::ext_php_rs::types::PhpType::Simple( + ::ext_php_rs::flags::DataType::Long + )) + .to_string() + ); + } + + #[test] + fn primitive_union_emits_vec_of_data_types() { + let ty: PhpType = "int|string|null".parse().unwrap(); + assert_eq!( + render(&ty), + quote!(::ext_php_rs::types::PhpType::Union(::std::vec![ + ::ext_php_rs::flags::DataType::Long, + ::ext_php_rs::flags::DataType::String, + ::ext_php_rs::flags::DataType::Null + ])) + .to_string() + ); + } + + #[test] + fn class_union_emits_owned_strings() { + let ty: PhpType = "Foo|Bar".parse().unwrap(); + assert_eq!( + render(&ty), + quote!(::ext_php_rs::types::PhpType::ClassUnion(::std::vec![ + ::std::string::String::from("Foo"), + ::std::string::String::from("Bar") + ])) + .to_string() + ); + } + + #[test] + fn intersection_emits_amp_joined_classes() { + let ty: PhpType = "Countable&Traversable".parse().unwrap(); + assert_eq!( + render(&ty), + quote!(::ext_php_rs::types::PhpType::Intersection(::std::vec![ + ::std::string::String::from("Countable"), + ::std::string::String::from("Traversable") + ])) + .to_string() + ); + } + + #[test] + fn dnf_emits_intersection_then_single() { + let ty: PhpType = "(A&B)|C".parse().unwrap(); + assert_eq!( + render(&ty), + quote!(::ext_php_rs::types::PhpType::Dnf(::std::vec![ + ::ext_php_rs::types::DnfTerm::Intersection(::std::vec![ + ::std::string::String::from("A"), + ::std::string::String::from("B") + ]), + ::ext_php_rs::types::DnfTerm::Single(::std::string::String::from("C")) + ])) + .to_string() + ); + } + + #[test] + fn dnf_term_single_round_trips() { + let term = DnfTerm::Single("X".to_owned()); + assert_eq!( + render(&term), + quote!(::ext_php_rs::types::DnfTerm::Single( + ::std::string::String::from("X") + )) + .to_string() + ); + } + + #[test] + fn data_type_token_path_lives_in_flags_module() { + // Sanity check that DataType emission stays under flags::, even + // when wrapped in PhpType::Simple. + let ty = PhpType::Simple(DataType::Object(None)); + assert!(render(&ty).contains("flags :: DataType :: Object")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn class_union_round_trips_through_clone_and_eq() { + let foo_or_bar = PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]); + assert_eq!(foo_or_bar.clone(), foo_or_bar); + } + + #[test] + fn class_union_is_distinct_from_primitive_union_and_simple() { + let class = PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]); + let primitive = PhpType::Union(vec![DataType::Long, DataType::String]); + let simple = PhpType::Simple(DataType::String); + + assert_ne!(class, primitive); + assert_ne!(class, simple); + } + + #[test] + fn intersection_round_trips_through_clone_and_eq() { + let countable_and_traversable = + PhpType::Intersection(vec!["Countable".to_owned(), "Traversable".to_owned()]); + assert_eq!(countable_and_traversable.clone(), countable_and_traversable); + } + + #[test] + fn intersection_is_distinct_from_class_union_simple_and_primitive_union() { + let intersection = PhpType::Intersection(vec!["Foo".to_owned(), "Bar".to_owned()]); + let class_union = PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]); + let primitive = PhpType::Union(vec![DataType::Long, DataType::String]); + let simple = PhpType::Simple(DataType::String); + + assert_ne!(intersection, class_union); + assert_ne!(intersection, primitive); + assert_ne!(intersection, simple); + } + + #[test] + fn dnf_round_trips_through_clone_and_eq() { + let dnf = PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]); + assert_eq!(dnf.clone(), dnf); + } + + #[test] + fn dnf_is_distinct_from_intersection_class_union_and_simple() { + let dnf = PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]); + let intersection = PhpType::Intersection(vec!["A".to_owned(), "B".to_owned()]); + let class_union = PhpType::ClassUnion(vec!["A".to_owned(), "C".to_owned()]); + let simple = PhpType::Simple(DataType::String); + + assert_ne!(dnf, intersection); + assert_ne!(dnf, class_union); + assert_ne!(dnf, simple); + } + + #[test] + fn dnf_term_round_trips_through_clone_and_eq() { + let single = DnfTerm::Single("Foo".to_owned()); + let group = DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]); + assert_eq!(single.clone(), single); + assert_eq!(group.clone(), group); + assert_ne!(single, group); + } + + #[test] + fn parses_int_primitive() { + let ty: PhpType = "int".parse().expect("int parses"); + assert_eq!(ty, PhpType::Simple(DataType::Long)); + } + + #[test] + fn parses_every_primitive_name() { + let cases: &[(&str, DataType)] = &[ + ("int", DataType::Long), + ("float", DataType::Double), + ("bool", DataType::Bool), + ("true", DataType::True), + ("false", DataType::False), + ("string", DataType::String), + ("array", DataType::Array), + ("object", DataType::Object(None)), + ("callable", DataType::Callable), + ("iterable", DataType::Iterable), + ("resource", DataType::Resource), + ("mixed", DataType::Mixed), + ("void", DataType::Void), + ("null", DataType::Null), + ]; + for &(name, expected) in cases { + let parsed: PhpType = name.parse().unwrap_or_else(|e| panic!("{name} → {e}")); + assert_eq!(parsed, PhpType::Simple(expected), "name = {name}"); + } + } + + #[test] + fn primitives_are_case_insensitive() { + for input in ["INT", "Int", "iNt"] { + let parsed: PhpType = input.parse().expect("case insensitive"); + assert_eq!(parsed, PhpType::Simple(DataType::Long), "input = {input}"); + } + } + + #[test] + fn parses_single_class_into_class_union() { + let parsed: PhpType = "Foo".parse().expect("class parses"); + assert_eq!(parsed, PhpType::ClassUnion(vec!["Foo".to_owned()])); + } + + #[test] + fn strips_leading_backslash_from_class_name() { + let parsed: PhpType = "\\Foo".parse().expect("\\Foo parses"); + assert_eq!(parsed, PhpType::ClassUnion(vec!["Foo".to_owned()])); + } + + #[test] + fn preserves_namespace_separators() { + let parsed: PhpType = "\\Ns\\Foo".parse().expect("namespaced class parses"); + assert_eq!(parsed, PhpType::ClassUnion(vec!["Ns\\Foo".to_owned()])); + } + + #[test] + fn class_names_keep_their_case() { + let parsed: PhpType = "FooBar".parse().expect("CamelCase preserved"); + assert_eq!(parsed, PhpType::ClassUnion(vec!["FooBar".to_owned()])); + } + + #[test] + fn parses_primitive_union() { + let parsed: PhpType = "int|string".parse().expect("union parses"); + assert_eq!( + parsed, + PhpType::Union(vec![DataType::Long, DataType::String]) + ); + } + + #[test] + fn parses_primitive_union_with_inline_null() { + let parsed: PhpType = "int|string|null".parse().expect("nullable union parses"); + assert_eq!( + parsed, + PhpType::Union(vec![DataType::Long, DataType::String, DataType::Null]) + ); + } + + #[test] + fn nullable_shorthand_canonicalises_to_union_for_primitives() { + let parsed: PhpType = "?int".parse().expect("?int parses"); + assert_eq!(parsed, PhpType::Union(vec![DataType::Long, DataType::Null])); + } + + #[test] + fn whitespace_around_pipes_is_tolerated() { + let parsed: PhpType = "int | string".parse().expect("whitespace tolerated"); + assert_eq!( + parsed, + PhpType::Union(vec![DataType::Long, DataType::String]) + ); + } + + #[test] + fn parses_class_union() { + let parsed: PhpType = "Foo|Bar".parse().expect("class union parses"); + assert_eq!( + parsed, + PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]) + ); + } + + #[test] + fn class_union_strips_backslashes_per_member() { + let parsed: PhpType = "\\Foo|\\Ns\\Bar".parse().expect("class union normalises"); + assert_eq!( + parsed, + PhpType::ClassUnion(vec!["Foo".to_owned(), "Ns\\Bar".to_owned()]) + ); + } + + #[test] + fn parses_bare_intersection() { + let parsed: PhpType = "Foo&Bar".parse().expect("intersection parses"); + assert_eq!( + parsed, + PhpType::Intersection(vec!["Foo".to_owned(), "Bar".to_owned()]) + ); + } + + #[test] + fn parses_three_way_bare_intersection() { + let parsed: PhpType = "A&B&C".parse().expect("3-way intersection parses"); + assert_eq!( + parsed, + PhpType::Intersection(vec!["A".to_owned(), "B".to_owned(), "C".to_owned()]) + ); + } + + #[test] + fn parses_dnf_group_then_single() { + let parsed: PhpType = "(A&B)|C".parse().expect("(A&B)|C parses"); + assert_eq!( + parsed, + PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]) + ); + } + + #[test] + fn parses_dnf_single_then_group() { + let parsed: PhpType = "C|(A&B)".parse().expect("C|(A&B) parses"); + assert_eq!( + parsed, + PhpType::Dnf(vec![ + DnfTerm::Single("C".to_owned()), + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + ]) + ); + } + + #[test] + fn parses_dnf_group_then_two_singles() { + let parsed: PhpType = "(A&B)|C|D".parse().expect("(A&B)|C|D parses"); + assert_eq!( + parsed, + PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + DnfTerm::Single("D".to_owned()), + ]) + ); + } + + #[test] + fn parses_dnf_group_strips_backslashes() { + let parsed: PhpType = "(\\A&\\B)|\\C".parse().expect("(\\A&\\B)|\\C parses"); + assert_eq!( + parsed, + PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]) + ); + } + + fn err(input: &str) -> PhpTypeParseError { + input.parse::().expect_err(input) + } + + #[test] + fn rejects_empty_input() { + assert_eq!(err(""), PhpTypeParseError::Empty); + assert_eq!(err(" "), PhpTypeParseError::Empty); + } + + #[test] + fn rejects_leading_pipe() { + assert!(matches!(err("|int"), PhpTypeParseError::EmptyTerm { .. })); + } + + #[test] + fn rejects_trailing_pipe() { + assert!(matches!(err("int|"), PhpTypeParseError::EmptyTerm { .. })); + } + + #[test] + fn rejects_double_pipe() { + assert!(matches!( + err("int||string"), + PhpTypeParseError::EmptyTerm { .. } + )); + } + + #[test] + fn rejects_unbalanced_paren() { + assert!(matches!( + err("(A&B|C"), + PhpTypeParseError::UnbalancedParens { .. } + )); + } + + #[test] + fn rejects_union_inside_intersection() { + assert!(matches!( + err("A&(B|C)"), + PhpTypeParseError::NakedAmpInUnion { .. } + | PhpTypeParseError::UnionInIntersection { .. } + )); + } + + #[test] + fn rejects_naked_amp_in_union() { + assert!(matches!( + err("A&B|C"), + PhpTypeParseError::NakedAmpInUnion { .. } + )); + } + + #[test] + fn rejects_nullable_compound_union() { + assert!(matches!( + err("?int|string"), + PhpTypeParseError::NullableCompound { .. } + )); + } + + #[test] + fn rejects_nullable_compound_intersection() { + assert!(matches!( + err("?A&B"), + PhpTypeParseError::NullableCompound { .. } + )); + } + + #[test] + fn rejects_unsupported_keywords() { + for kw in ["static", "never", "self", "parent"] { + assert!( + matches!(err(kw), PhpTypeParseError::UnsupportedKeyword { .. }), + "{kw} should be rejected" + ); + } + } + + #[test] + fn rejects_class_nullable_simple() { + assert_eq!( + err("?Foo"), + PhpTypeParseError::ClassNullableNotRepresentable + ); + } + + #[test] + fn rejects_class_nullable_pipe_null() { + assert_eq!( + err("Foo|null"), + PhpTypeParseError::ClassNullableNotRepresentable + ); + } + + #[test] + fn rejects_class_union_with_null_member() { + assert_eq!( + err("Foo|Bar|null"), + PhpTypeParseError::ClassNullableNotRepresentable + ); + } + + #[test] + fn rejects_dnf_with_null_member() { + assert_eq!( + err("(A&B)|null"), + PhpTypeParseError::ClassNullableNotRepresentable + ); + } + + #[test] + fn rejects_mixed_primitive_and_class() { + assert_eq!(err("int|Foo"), PhpTypeParseError::MixedPrimitiveAndClass); + } + + #[test] + fn rejects_single_element_paren_group() { + assert!(matches!( + err("(A)|B"), + PhpTypeParseError::IntersectionTooSmall { .. } + )); + } + + #[test] + fn rejects_primitive_in_intersection() { + assert!(matches!( + err("A&int"), + PhpTypeParseError::PrimitiveInIntersection { .. } + )); + assert!(matches!( + err("(A&int)|C"), + PhpTypeParseError::PrimitiveInIntersection { .. } + )); + } + + #[test] + fn rejects_duplicate_in_union() { + assert!(matches!( + err("int|int"), + PhpTypeParseError::DuplicateMember { .. } + )); + } + + #[test] + fn rejects_duplicate_in_class_union() { + assert!(matches!( + err("Foo|Foo"), + PhpTypeParseError::DuplicateMember { .. } + )); + } + + #[test] + fn rejects_duplicate_in_intersection() { + assert!(matches!( + err("A&B&A"), + PhpTypeParseError::DuplicateMember { .. } + )); + } + + #[test] + fn rejects_duplicate_in_dnf() { + assert!(matches!( + err("(A&B)|C|C"), + PhpTypeParseError::DuplicateMember { .. } + )); + } + + #[test] + fn display_simple_primitives_match_php_names() { + let cases: &[(DataType, &str)] = &[ + (DataType::Long, "int"), + (DataType::Double, "float"), + (DataType::Bool, "bool"), + (DataType::True, "true"), + (DataType::False, "false"), + (DataType::String, "string"), + (DataType::Array, "array"), + (DataType::Object(None), "object"), + (DataType::Callable, "callable"), + (DataType::Iterable, "iterable"), + (DataType::Resource, "resource"), + (DataType::Mixed, "mixed"), + (DataType::Void, "void"), + (DataType::Null, "null"), + ]; + for &(dt, expected) in cases { + let s = format!("{}", PhpType::Simple(dt)); + assert_eq!(s, expected, "DataType::{dt:?}"); + } + } + + #[test] + fn display_class_union_adds_leading_backslash() { + let ty = PhpType::ClassUnion(vec!["Foo".to_owned(), "Ns\\Bar".to_owned()]); + assert_eq!(format!("{ty}"), "\\Foo|\\Ns\\Bar"); + } + + #[test] + fn display_intersection_renders_amp_separated() { + let ty = PhpType::Intersection(vec!["A".to_owned(), "B".to_owned()]); + assert_eq!(format!("{ty}"), "\\A&\\B"); + } + + #[test] + fn display_dnf_wraps_intersection_groups_in_parens() { + let ty = PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]); + assert_eq!(format!("{ty}"), "(\\A&\\B)|\\C"); + } + + #[test] + fn display_union_pipe_separated_with_inline_null() { + let ty = PhpType::Union(vec![DataType::Long, DataType::String, DataType::Null]); + assert_eq!(format!("{ty}"), "int|string|null"); + } + + #[test] + fn display_already_qualified_class_does_not_double_backslash() { + let ty = PhpType::ClassUnion(vec!["\\AlreadyQualified".to_owned()]); + assert_eq!(format!("{ty}"), "\\AlreadyQualified"); + } + + #[test] + fn roundtrip_happy_path_corpus() { + let inputs = [ + "int", + "string", + "bool", + "void", + "null", + "object", + "iterable", + "callable", + "Foo", + "\\Foo", + "\\Ns\\Foo", + "int|string", + "int|string|null", + "?int", + "Foo|Bar", + "\\Foo|\\Bar", + "Foo&Bar", + "A&B&C", + "(A&B)|C", + "C|(A&B)", + "(A&B)|C|D", + "(\\A&\\B)|\\C", + "int | string", + ]; + for input in inputs { + let parsed: PhpType = input.parse().unwrap_or_else(|e| panic!("{input} → {e}")); + let rendered = format!("{parsed}"); + let reparsed: PhpType = rendered + .parse() + .unwrap_or_else(|e| panic!("reparse {rendered} → {e}")); + assert_eq!( + parsed, reparsed, + "input {input:?} rendered as {rendered:?} did not roundtrip" + ); + } + } +} diff --git a/docsrs_bindings.rs b/docsrs_bindings.rs index 6241b06fc..2a9cc6e2a 100644 --- a/docsrs_bindings.rs +++ b/docsrs_bindings.rs @@ -478,6 +478,10 @@ impl __BindgenBitfieldUnit<[u8; N]> { 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_ARENA_BIT: u32 = 1048576; +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; @@ -749,6 +753,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, @@ -2500,6 +2510,16 @@ unsafe extern "C" { callable_name: *mut *mut zend_string, ) -> bool; } +unsafe extern "C" { + pub fn zend_declare_typed_property( + ce: *mut zend_class_entry, + name: *mut zend_string, + property: *mut zval, + access_type: ::std::os::raw::c_int, + doc_comment: *mut zend_string, + type_: zend_type, + ) -> *mut zend_property_info; +} unsafe extern "C" { pub fn zend_declare_property( ce: *mut zend_class_entry, @@ -2606,9 +2626,17 @@ pub const _zend_expected_type_Z_EXPECTED_OBJECT_OR_STRING: _zend_expected_type = pub const _zend_expected_type_Z_EXPECTED_OBJECT_OR_STRING_OR_NULL: _zend_expected_type = 33; pub const _zend_expected_type_Z_EXPECTED_LAST: _zend_expected_type = 34; pub type _zend_expected_type = ::std::os::raw::c_uint; +pub use self::_zend_expected_type as zend_expected_type; unsafe extern "C" { pub fn zend_wrong_parameters_count_error(min_num_args: u32, max_num_args: u32); } +unsafe extern "C" { + pub fn zend_wrong_parameter_type_error( + num: u32, + expected_type: zend_expected_type, + arg: *mut zval, + ); +} unsafe extern "C" { pub fn php_printf(format: *const ::std::os::raw::c_char, ...) -> usize; } diff --git a/examples/hello_world.rs b/examples/hello_world.rs index bb988b0d4..9894112bc 100644 --- a/examples/hello_world.rs +++ b/examples/hello_world.rs @@ -1,6 +1,10 @@ #![allow(missing_docs, clippy::must_use_candidate)] #![cfg_attr(windows, feature(abi_vectorcall))] -use ext_php_rs::{constant::IntoConst, prelude::*, types::ZendClassObject}; +use ext_php_rs::{ + constant::IntoConst, + prelude::*, + types::{ZendClassObject, Zval}, +}; #[derive(Debug)] #[php_class] @@ -72,6 +76,71 @@ pub fn hello_world() -> &'static str { "Hello, world!" } +/// Demonstrates compound PHP type hints. The argument accepts `int|string` +/// and the return type registers as `int|string|null`. Both strings are +/// parsed at macro-expansion time, so a typo such as `?Foo&Bar` would +/// fail at `cargo build` rather than at extension load. +#[php_function] +#[php(returns = "int|string|null")] +pub fn flexible_id(#[php(types = "int|string")] _value: &Zval) -> Option { + None +} + +/// Companion to `flexible_id` showing that the same compile-time parsing +/// works for class-side type strings. The literal `\TestClass|\OtherTestClass` +/// is parsed at macro-expansion time and resolves the class names against +/// PHP's global namespace at extension load. Use a leading `\` for the +/// fully qualified name; bare `TestClass` works too because the engine +/// places `#[php_class]`-defined structs in the global namespace. +#[php_function] +pub fn accept_class_value(#[php(types = "\\TestClass|\\OtherTestClass")] _value: &Zval) {} + +/// Demonstrates `#[php(returns = "...")]` widening the inferred return +/// metadata. The Rust signature returns a concrete `TestClass`, so the +/// macro would otherwise register the return type as just `\TestClass`. +/// The override widens it to `\TestClass|\OtherTestClass`, which is +/// useful when a function returns one specific subtype today but the +/// PHP-side contract should leave room for a wider set of legal +/// values. Reflection on this function reports the wider union. +#[php_function] +#[php(returns = "\\TestClass|\\OtherTestClass")] +pub fn produce_test_class_or_other() -> TestClass { + TestClass { + a: 0, + b: 0, + name: "from union".into(), + optional: None, + max_limit: 100, + } +} + +/// Demonstrates `#[derive(PhpUnion)]` for primitive-typed variants. The +/// derive synthesises `PhpType::Union` from `::TYPE` of +/// each variant, so the registered metadata is `int|float` here. Use +/// this when your union is fully captured by Rust enum dispatch and +/// every variant is a primitive that already implements `IntoZval` and +/// `FromZval` on its owned form. Class-typed variants are not yet +/// supported by the derive (tracked as a slice 7 follow-up); for +/// class unions today, prefer the `#[php(returns = "\Foo|\Bar")]` +/// override shown in `produce_test_class_or_other` above. +#[derive(PhpUnion)] +pub enum IntOrFloat { + Int(i64), + Float(f64), +} + +#[php_function] +pub fn pick_number(use_float: bool) -> IntOrFloat { + if use_float { + IntOrFloat::Float(2.5) + } else { + IntOrFloat::Int(42) + } +} + +#[php_class] +pub struct OtherTestClass; + #[php_const] pub const HELLO_WORLD: i32 = 100; @@ -103,7 +172,12 @@ fn startup(_ty: i32, mod_num: i32) -> i32 { pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { module .class::() + .class::() .function(wrap_function!(hello_world)) + .function(wrap_function!(flexible_id)) + .function(wrap_function!(accept_class_value)) + .function(wrap_function!(produce_test_class_or_other)) + .function(wrap_function!(pick_number)) .function(wrap_function!(new_class)) .function(wrap_function!(get_zval_convert)) .constant(wrap_constant!(HELLO_WORLD)) diff --git a/flake.nix b/flake.nix index 9bf865e5f..c5317483e 100644 --- a/flake.nix +++ b/flake.nix @@ -21,6 +21,10 @@ php-dev = php.unwrapped.dev; php-zts = (pkgs.php.override { ztsSupport = true; }).buildEnv { embedSupport = true; }; php-zts-dev = php-zts.unwrapped.dev; + php82 = pkgs.php82.buildEnv { embedSupport = true; }; + php82-dev = php82.unwrapped.dev; + php83 = pkgs.php83.buildEnv { embedSupport = true; }; + php83-dev = php83.unwrapped.dev; mkShellFor = phpPkg: phpDevPkg: pkgs.mkShell { buildInputs = with pkgs; [ phpPkg @@ -42,6 +46,8 @@ devShells.${system} = { default = mkShellFor php php-dev; zts = mkShellFor php-zts php-zts-dev; + php82 = mkShellFor php82 php82-dev; + php83 = mkShellFor php83 php83-dev; }; }; } diff --git a/guide/src/SUMMARY.md b/guide/src/SUMMARY.md index af5fc323b..062b53c70 100644 --- a/guide/src/SUMMARY.md +++ b/guide/src/SUMMARY.md @@ -35,6 +35,7 @@ - [Constants](./macros/constant.md) - [PHP Functions](./macros/extern.md) - [`ZvalConvert`](./macros/zval_convert.md) + - [`PhpUnion`](./macros/php_union.md) - [`Attributes`](./macros/php.md) - [Exceptions](./exceptions.md) - [Output](./output.md) diff --git a/guide/src/macros/function.md b/guide/src/macros/function.md index 4f7a2962d..4258be533 100644 --- a/guide/src/macros/function.md +++ b/guide/src/macros/function.md @@ -122,6 +122,114 @@ pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { # fn main() {} ``` +## Overriding the registered PHP type + +Rust signatures can express many but not all PHP types. Compound types such as +primitive unions (`int|string`), class unions (`\Foo|\Bar`), intersections +(`\Countable&\Traversable`) and DNF (`(\A&\B)|\C`) cannot be derived from a +single Rust type via the `IntoZval`/`FromZval` trait path. + +The `#[php(types = "...")]` attribute on a parameter and the +`#[php(returns = "...")]` attribute on a function override the registered PHP +type metadata. The string is parsed at macro-expansion time by +`PhpType::from_str`; the syntax matches the PHP type-hint grammar (with `\` +for namespace separators). + +```rust,ignore +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; + +#[php_function] +#[php(returns = "int|string|null")] +pub fn flexible_id( + #[php(types = "int|string")] _id: &Zval, +) -> i64 { + 0 +} +``` + +The same attributes accept class names from your `#[php_class]`-defined +structs. The string is the canonical PHP type-hint grammar, so unions +(`\Foo|\Bar`), intersections (`\Countable&\Traversable`), and DNF +(`(\A&\B)|\C`) all work: + +```rust,ignore +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; + +#[php_class] +pub struct Foo; + +#[php_class] +pub struct Bar; + +#[php_function] +pub fn accept(#[php(types = "\\Foo|\\Bar")] _value: &Zval) {} + +#[php_function] +#[php(returns = "\\Foo|\\Bar")] +pub fn produce() -> Foo { Foo } +``` + +Use a leading `\` to anchor the class in PHP's global namespace, matching +how PHP code spells fully qualified names. Bare names without the leading +`\` work too: `\Foo` and `Foo` produce the same registered metadata, +because every `#[php_class]`-defined struct is placed in PHP's global +namespace by the engine. + +The override is the source of truth for the PHP type, including nullability — +put `null` in the string if the parameter or return should be nullable. The +runtime modifiers (`default`, `optional`, variadic, by-reference) are +orthogonal to type and still apply. + +Parsing runs once, at compile time. A parser-rejected string becomes a +`compile_error!` spanned on the literal, so `cargo build` surfaces the +diagnostic before the extension ever loads. Two shapes are deliberately +rejected: + +- **`?Foo&Bar`**: a leading `?` on an intersection is not legal PHP, + and the parser refuses it. The legal nullable form `(Foo&Bar)|null` + requires DNF (PHP 8.2+ in user code, 8.3+ on internal `arg_info`; see + the version constraint below). +- **Class-side nullables (`?Foo`, `\Foo|null`, `\Foo|\Bar|null`, + `(\A&\B)|null`)**: the parser refuses these because the class-side + variants of `PhpType` cannot carry an inline `null` member today. + This is a known asymmetry with the primitive side, which DOES accept + `int|null` (since `DataType::Null` is a primitive variant). For a + class-side function that may return null today, use a function whose + Rust return type is unconditional and pass nullable values through a + separate `null` return path managed by the caller; or wait on the + parser follow-up that will surface a `parse_with_nullable(&str)` + variant. + +The same attributes work inside `#[php_impl]`: + +```rust,ignore +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; + +#[php_class] +pub struct MyClass; + +#[php_impl] +impl MyClass { + pub fn __construct() -> Self { Self } + + pub fn accept( + &self, + #[php(types = "int|string")] _value: &Zval, + ) -> i64 { 1 } + + #[php(returns = "int|string|null")] + pub fn produce(&self) -> i64 { 0 } +} +``` + +Version constraint: intersection and DNF type hints on internal `arg_info` +require PHP 8.3 or newer. On 8.1/8.2 the runtime returns +`Err(InvalidCString)` from `Arg::as_arg_info` for those shapes; build the +test extension on 8.3+ if you need them. + ## Variadic Functions Variadic functions can be implemented by specifying the last argument in the Rust diff --git a/guide/src/macros/php_union.md b/guide/src/macros/php_union.md new file mode 100644 index 000000000..b524dedde --- /dev/null +++ b/guide/src/macros/php_union.md @@ -0,0 +1,109 @@ +# `PhpUnion` Derive Macro + +The `#[derive(PhpUnion)]` macro lets a Rust enum stand in for a PHP union type +on `#[php_function]` and `#[php_impl]` signatures. Each variant must +newtype-wrap exactly one field; the inner type must implement `IntoZval` and +`FromZval`. + +## What it emits + +The derive emits three impls on the enum: + +- `impl PhpUnion` whose `union_types()` returns + `PhpType::Union(vec![::TYPE, ::TYPE, ...])`. +- `impl IntoZval` whose `set_zval` dispatches on the variant and whose + `php_type()` override delegates to `::union_types()`. This + is what causes the function macro to register the right `int|string` shape. +- `impl FromZval` whose `from_zval` tries each variant's inner type in + declaration order. The same `php_type()` override applies. + +## Variant shapes + +Only newtype variants are accepted in the first iteration: + +```rust,ignore +#[derive(PhpUnion)] +pub enum IntOrString { + Int(i64), + Str(String), +} +``` + +The derive rejects, with a span on the offending variant: + +- unit variants (`None`), +- struct variants (`{ a: i32 }`), +- multi-field tuple variants (`(i32, String)`). + +Generics on the enum are also rejected. Both restrictions can be lifted in a +follow-up if demand surfaces. + +## Variant ordering + +`FromZval` walks variants in declaration order and stops on the first match. +Order matters when two inner types accept the same PHP value — for example, a +`String` variant before a `ParsedStr(String)` variant would always win even +when the zval is a numeric string. List the more specific variant first. + +## Example + +```rust,no_run,ignore +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; + +#[derive(PhpUnion)] +pub enum IntOrString { + Int(i64), + Str(String), +} + +#[php_function] +pub fn echo_either(value: IntOrString) -> IntOrString { + value +} + +#[php_module] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module.function(wrap_function!(echo_either)) +} +# fn main() {} +``` + +Use from PHP: + +```php +echo_either(42); // returns int(42), takes the IntOrString::Int branch +echo_either('hi'); // returns string(2) "hi", takes the IntOrString::Str branch +``` + +PHP's reflection sees the registered union on both sides: + +```php +$rf = new ReflectionFunction('echo_either'); +$param = $rf->getParameters()[0]->getType(); // ReflectionUnionType: int|string +$ret = $rf->getReturnType(); // ReflectionUnionType: int|string +``` + +## Relationship to `#[derive(ZvalConvert)]` + +`#[derive(ZvalConvert)]` on an enum produces a similar variant-dispatching +`IntoZval`/`FromZval`, but registers the parameter as `mixed` because it has +no way to express the union at registration time. `#[derive(PhpUnion)]` +overrides `php_type()` so the function macro registers `int|string`, +`int|string|null`, etc. as appropriate. + +If the enum is only ever consumed from Rust (never crossing into PHP through +a registered function), `ZvalConvert` is enough. The moment you want PHP +reflection or strict-types coercion to see the actual union members, prefer +`PhpUnion`. + +## Relationship to `#[php(types = "...")]` + +The `#[php(types = "int|string")]` attribute is the explicit override +when a Rust signature is `&Zval` or otherwise can't carry the type information +in the type system. `PhpUnion` is the type-driven path: the type itself +encodes the union, so the function signature is plain Rust and the macro +infers the PHP shape from the derive. Use the attribute when you want to +accept a raw `Zval` and inspect it manually; use `PhpUnion` when the variants +already carry the right Rust types. diff --git a/guide/src/types/index.md b/guide/src/types/index.md index 05b2da1aa..c1417bc61 100644 --- a/guide/src/types/index.md +++ b/guide/src/types/index.md @@ -34,3 +34,19 @@ 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`. + +## Compound PHP types + +`int|string`, `Foo|Bar`, `Countable&Traversable`, and `(A&B)|C` are all +expressible at the `Arg` and `FunctionBuilder` layer through the [`PhpType`] +enum. Two ergonomic paths surface this on `#[php_function]` and +`#[php_impl]` signatures: + +- The [`#[php(types = "...")]`](../macros/php.md) attribute, which takes a + PHP type string and parses it at macro-expansion time — invalid syntax + becomes a `compile_error!` spanned on the literal. +- The [`#[derive(PhpUnion)]`](../macros/php_union.md) macro, which lets you + model a union as a Rust enum and have the macro infer the registered shape + from the variants. + +[`PhpType`]: https://docs.rs/ext-php-rs/latest/ext_php_rs/types/enum.PhpType.html diff --git a/src/args.rs b/src/args.rs index c110b3b79..fda4f8686 100644 --- a/src/args.rs +++ b/src/args.rs @@ -6,15 +6,8 @@ use crate::{ convert::{FromZvalMut, IntoZvalDyn}, describe::{Parameter, abi}, error::{Error, Result}, - ffi::{ - _zend_expected_type, _zend_expected_type_Z_EXPECTED_ARRAY, - _zend_expected_type_Z_EXPECTED_BOOL, _zend_expected_type_Z_EXPECTED_DOUBLE, - _zend_expected_type_Z_EXPECTED_LONG, _zend_expected_type_Z_EXPECTED_OBJECT, - _zend_expected_type_Z_EXPECTED_RESOURCE, _zend_expected_type_Z_EXPECTED_STRING, - zend_internal_arg_info, zend_wrong_parameters_count_error, - }, - flags::DataType, - types::Zval, + ffi::{zend_internal_arg_info, zend_wrong_parameters_count_error}, + types::{PhpType, Zval}, zend::ZendType, }; @@ -23,7 +16,7 @@ use crate::{ #[derive(Debug)] pub struct Arg<'a> { name: String, - r#type: DataType, + r#type: PhpType, as_ref: bool, allow_null: bool, pub(crate) variadic: bool, @@ -38,11 +31,18 @@ impl<'a> Arg<'a> { /// # Parameters /// /// * `name` - The name of the parameter. - /// * `_type` - The type of the parameter. - pub fn new>(name: T, r#type: DataType) -> Self { + /// * `ty` - The type of the parameter. Accepts a + /// [`crate::flags::DataType`] for the single-type case (via + /// [`From for PhpType`]) or a full [`PhpType`] + /// for compound forms such as [`PhpType::Union`]. + pub fn new(name: T, ty: U) -> Self + where + T: Into, + U: Into, + { Arg { name: name.into(), - r#type, + r#type: ty.into(), as_ref: false, allow_null: false, variadic: false, @@ -156,17 +156,84 @@ impl<'a> Arg<'a> { self.zval.as_ref().ok_or(Error::Callable)?.try_call(params) } + /// Returns the legacy `Z_EXPECTED_*` discriminant for this argument. + /// + /// This is a thin projection used by extensions that drive PHP's legacy + /// ZPP error path (`zend_wrong_parameter_type_error`) themselves. The + /// discriminant enum predates compound types: PHP itself uses + /// `zend_argument_type_error` with a custom format string for unions and + /// intersections (see `Zend/zend_API.c` and `ext/standard/array.c`). + /// + /// For compound declared types, format the type via [`Arg::ty`] and + /// throw a [`crate::exception::PhpException`] instead. + /// + /// # Errors + /// + /// * [`Error::NoExpectedTypeDiscriminant`] - the argument's declared + /// type has no equivalent in PHP's `Z_EXPECTED_*` enum (compound + /// types or scalar [`crate::flags::DataType`] variants without a slot, + /// such as `Mixed`, `Void`, `Iterable`, `Callable`, `Null`). + pub fn expected_type(&self) -> Result { + let dt = match &self.r#type { + PhpType::Simple(dt) => *dt, + _ => return Err(Error::NoExpectedTypeDiscriminant), + }; + crate::zend::ExpectedType::from_simple(dt, self.allow_null) + .ok_or(Error::NoExpectedTypeDiscriminant) + } + + /// Returns the declared PHP type for this argument. + /// + /// Use [`std::fmt::Display`] on the result (e.g. + /// `format!("{}", arg.ty())`) to render the canonical PHP-syntax + /// string for the type, including unions, intersections, and DNF. + #[must_use] + pub fn ty(&self) -> &PhpType { + &self.r#type + } + /// Returns the internal PHP argument info. pub(crate) fn as_arg_info(&self) -> Result { - Ok(ArgInfo { - name: CString::new(self.name.as_str())?.into_raw(), - type_: ZendType::empty_from_type( - self.r#type, + let zend_type = match &self.r#type { + PhpType::Simple(dt) => { + ZendType::empty_from_type(*dt, self.as_ref, self.variadic, self.allow_null) + .ok_or(Error::InvalidCString)? + } + PhpType::Union(types) => ZendType::empty_from_primitive_union( + types, self.as_ref, self.variadic, self.allow_null, ) .ok_or(Error::InvalidCString)?, + PhpType::ClassUnion(class_names) => ZendType::empty_from_class_union( + class_names, + self.as_ref, + self.variadic, + self.allow_null, + ) + .ok_or(Error::InvalidCString)?, + #[cfg(php83)] + PhpType::Intersection(class_names) => ZendType::empty_from_class_intersection( + class_names, + self.as_ref, + self.variadic, + self.allow_null, + ) + .ok_or(Error::InvalidCString)?, + #[cfg(not(php83))] + PhpType::Intersection(_) => return Err(Error::InvalidCString), + #[cfg(php83)] + PhpType::Dnf(terms) => { + ZendType::empty_from_dnf(terms, self.as_ref, self.variadic, self.allow_null) + .ok_or(Error::InvalidCString)? + } + #[cfg(not(php83))] + PhpType::Dnf(_) => return Err(Error::InvalidCString), + }; + Ok(ArgInfo { + name: CString::new(self.name.as_str())?.into_raw(), + type_: zend_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(), @@ -176,28 +243,11 @@ impl<'a> Arg<'a> { } } -impl From> for _zend_expected_type { - fn from(arg: Arg) -> Self { - let type_id = match arg.r#type { - 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!(), - }; - - if arg.allow_null { type_id + 1 } else { type_id } - } -} - impl From> for Parameter { fn from(val: Arg<'_>) -> Self { Parameter { name: val.name.into(), - ty: Some(val.r#type).into(), + ty: Some(val.r#type.into()).into(), nullable: val.allow_null, variadic: val.variadic, default: val.default_value.map(abi::RString::from).into(), @@ -306,6 +356,7 @@ mod tests { #![allow(clippy::unwrap_used)] #[cfg(feature = "embed")] use crate::embed::Embed; + use crate::flags::DataType; use super::*; @@ -313,7 +364,7 @@ mod tests { fn test_new() { let arg = Arg::new("test", DataType::Long); assert_eq!(arg.name, "test"); - assert_eq!(arg.r#type, DataType::Long); + assert_eq!(arg.r#type, PhpType::Simple(DataType::Long)); assert!(!arg.as_ref); assert!(!arg.allow_null); assert!(!arg.variadic); @@ -322,6 +373,18 @@ mod tests { assert!(arg.variadic_zvals.is_empty()); } + #[test] + fn test_new_with_union() { + let arg = Arg::new( + "test", + PhpType::Union(vec![DataType::Long, DataType::String]), + ); + assert_eq!( + arg.r#type, + PhpType::Union(vec![DataType::Long, DataType::String]) + ); + } + #[test] fn test_as_ref() { let arg = Arg::new("test", DataType::Long).as_ref(); @@ -468,73 +531,6 @@ mod tests { assert_eq!(r#type.type_mask, 16); } - #[test] - fn test_type_from_arg() { - let arg = Arg::new("test", DataType::Long); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 0); - - let arg = Arg::new("test", DataType::Long).allow_null(); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 1); - - let arg = Arg::new("test", DataType::False); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 2); - - let arg = Arg::new("test", DataType::False).allow_null(); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 3); - - let arg = Arg::new("test", DataType::True); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 2); - - let arg = Arg::new("test", DataType::True).allow_null(); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 3); - - let arg = Arg::new("test", DataType::String); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 4); - - let arg = Arg::new("test", DataType::String).allow_null(); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 5); - - let arg = Arg::new("test", DataType::Array); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 6); - - let arg = Arg::new("test", DataType::Array).allow_null(); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 7); - - let arg = Arg::new("test", DataType::Resource); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 14); - - let arg = Arg::new("test", DataType::Resource).allow_null(); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 15); - - let arg = Arg::new("test", DataType::Object(None)); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 18); - - let arg = Arg::new("test", DataType::Object(None)).allow_null(); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 19); - - let arg = Arg::new("test", DataType::Double); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 20); - - let arg = Arg::new("test", DataType::Double).allow_null(); - let actual: _zend_expected_type = arg.into(); - assert_eq!(actual, 21); - } - #[test] fn test_param_from_arg() { let arg = Arg::new("test", DataType::Long) @@ -542,7 +538,10 @@ mod tests { .allow_null(); let param: Parameter = arg.into(); assert_eq!(param.name, "test".into()); - assert_eq!(param.ty, abi::Option::Some(DataType::Long)); + assert_eq!( + param.ty, + abi::Option::Some(crate::describe::PhpTypeAbi::Simple(DataType::Long)) + ); assert!(param.nullable); assert_eq!(param.default, abi::Option::Some("default".into())); } @@ -564,8 +563,252 @@ mod tests { parser = parser.arg(&mut arg); assert_eq!(parser.args.len(), 1); assert_eq!(parser.args[0].name, "test"); - assert_eq!(parser.args[0].r#type, DataType::Long); + assert_eq!(parser.args[0].r#type, PhpType::Simple(DataType::Long)); + } + + #[test] + #[cfg(php83)] + fn class_union_arg_emits_literal_name_with_pipe_joined_classes() { + use crate::ffi::_ZEND_TYPE_LITERAL_NAME_BIT; + use std::ffi::CStr; + + let arg = Arg::new( + "value", + PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]), + ); + let arg_info = arg.as_arg_info().expect("class union should build"); + + assert_ne!( + arg_info.type_.type_mask & _ZEND_TYPE_LITERAL_NAME_BIT, + 0, + "literal-name bit must be set on PHP 8.3+", + ); + assert!(!arg_info.type_.ptr.is_null()); + + let class_str = unsafe { CStr::from_ptr(arg_info.type_.ptr.cast()) }; + assert_eq!(class_str.to_str().unwrap(), "Foo|Bar"); + } + + #[test] + #[cfg(php83)] + fn class_union_arg_with_allow_null_sets_nullable_bit() { + use crate::ffi::_ZEND_TYPE_NULLABLE_BIT; + + let arg = Arg::new( + "value", + PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]), + ) + .allow_null(); + let arg_info = arg + .as_arg_info() + .expect("nullable class union should build"); + + assert_ne!( + arg_info.type_.type_mask & _ZEND_TYPE_NULLABLE_BIT, + 0, + "allow_null must propagate _ZEND_TYPE_NULLABLE_BIT", + ); + } + + #[test] + fn class_union_arg_with_empty_member_list_errors() { + let arg = Arg::new("value", PhpType::ClassUnion(vec![])); + assert!(arg.as_arg_info().is_err()); + } + + #[test] + #[cfg(php83)] + fn intersection_arg_emits_list_with_intersection_bit() { + use crate::ffi::{_ZEND_TYPE_INTERSECTION_BIT, _ZEND_TYPE_LIST_BIT}; + + let arg = Arg::new( + "value", + PhpType::Intersection(vec!["Countable".to_owned(), "Traversable".to_owned()]), + ); + let arg_info = arg.as_arg_info().expect("intersection should build"); + + assert_ne!(arg_info.type_.type_mask & _ZEND_TYPE_LIST_BIT, 0); + assert_ne!(arg_info.type_.type_mask & _ZEND_TYPE_INTERSECTION_BIT, 0); + assert!(!arg_info.type_.ptr.is_null()); + } + + #[test] + #[cfg(php83)] + fn intersection_arg_with_allow_null_errors() { + let arg = Arg::new( + "value", + PhpType::Intersection(vec!["Foo".to_owned(), "Bar".to_owned()]), + ) + .allow_null(); + assert!( + arg.as_arg_info().is_err(), + "nullable intersection must error: DNF lands in slice 04" + ); + } + + #[test] + #[cfg(php83)] + fn intersection_arg_with_empty_member_list_errors() { + let arg = Arg::new("value", PhpType::Intersection(vec![])); + assert!(arg.as_arg_info().is_err()); + } + + #[test] + #[cfg(php83)] + fn dnf_arg_emits_outer_list_with_union_arena_bits() { + use crate::ffi::{_ZEND_TYPE_ARENA_BIT, _ZEND_TYPE_LIST_BIT, _ZEND_TYPE_UNION_BIT}; + use crate::types::DnfTerm; + + let arg = Arg::new( + "value", + PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]), + ); + let arg_info = arg.as_arg_info().expect("DNF arg should build"); + + assert_ne!(arg_info.type_.type_mask & _ZEND_TYPE_LIST_BIT, 0); + assert_ne!(arg_info.type_.type_mask & _ZEND_TYPE_UNION_BIT, 0); + assert_ne!(arg_info.type_.type_mask & _ZEND_TYPE_ARENA_BIT, 0); + assert!(!arg_info.type_.ptr.is_null()); + } + + #[test] + #[cfg(php83)] + fn dnf_arg_with_allow_null_sets_nullable_bit() { + use crate::ffi::_ZEND_TYPE_NULLABLE_BIT; + use crate::types::DnfTerm; + + let arg = Arg::new( + "value", + PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]), + ) + .allow_null(); + let arg_info = arg.as_arg_info().expect("nullable DNF arg should build"); + + assert_ne!(arg_info.type_.type_mask & _ZEND_TYPE_NULLABLE_BIT, 0); + } + + #[test] + #[cfg(php83)] + fn dnf_arg_empty_terms_errors() { + let arg = Arg::new("value", PhpType::Dnf(vec![])); + assert!(arg.as_arg_info().is_err()); } // TODO: test parse + + #[test] + fn expected_type_for_simple_long() { + let arg = Arg::new("v", DataType::Long); + let got = arg.expected_type().expect("simple long should map"); + assert_eq!(got, crate::zend::ExpectedType::Long); + } + + #[test] + fn expected_type_for_nullable_simple_long() { + let arg = Arg::new("v", DataType::Long).allow_null(); + let got = arg.expected_type().expect("nullable long should map"); + assert_eq!(got, crate::zend::ExpectedType::LongOrNull); + } + + #[test] + fn expected_type_for_simple_object() { + let arg = Arg::new("v", DataType::Object(Some("Foo"))); + let got = arg.expected_type().expect("simple object should map"); + assert_eq!(got, crate::zend::ExpectedType::Object); + } + + #[test] + fn expected_type_for_nullable_object() { + let arg = Arg::new("v", DataType::Object(None)).allow_null(); + let got = arg.expected_type().expect("nullable object should map"); + assert_eq!(got, crate::zend::ExpectedType::ObjectOrNull); + } + + #[test] + fn expected_type_for_unmappable_simple_returns_no_discriminant() { + let arg = Arg::new("v", DataType::Mixed); + assert!(matches!( + arg.expected_type(), + Err(Error::NoExpectedTypeDiscriminant) + )); + } + + #[test] + fn expected_type_for_primitive_union_returns_no_discriminant() { + let arg = Arg::new("v", PhpType::Union(vec![DataType::Long, DataType::String])); + assert!(matches!( + arg.expected_type(), + Err(Error::NoExpectedTypeDiscriminant) + )); + } + + #[test] + fn expected_type_for_class_union_returns_no_discriminant() { + let arg = Arg::new( + "v", + PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]), + ); + assert!(matches!( + arg.expected_type(), + Err(Error::NoExpectedTypeDiscriminant) + )); + } + + #[test] + fn expected_type_for_intersection_returns_no_discriminant() { + let arg = Arg::new( + "v", + PhpType::Intersection(vec!["Countable".to_owned(), "Traversable".to_owned()]), + ); + assert!(matches!( + arg.expected_type(), + Err(Error::NoExpectedTypeDiscriminant) + )); + } + + #[test] + fn ty_returns_simple_php_type() { + let arg = Arg::new("v", DataType::Long); + assert_eq!(arg.ty(), &PhpType::Simple(DataType::Long)); + } + + #[test] + fn ty_returns_union_php_type() { + let arg = Arg::new("v", PhpType::Union(vec![DataType::Long, DataType::String])); + assert_eq!( + arg.ty(), + &PhpType::Union(vec![DataType::Long, DataType::String]) + ); + } + + #[test] + fn ty_renders_as_php_syntax_string() { + let arg = Arg::new( + "v", + PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]), + ); + assert_eq!(format!("{}", arg.ty()), "\\Foo|\\Bar"); + } + + #[test] + fn expected_type_for_dnf_returns_no_discriminant() { + use crate::types::DnfTerm; + let arg = Arg::new( + "v", + PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]), + ); + assert!(matches!( + arg.expected_type(), + Err(Error::NoExpectedTypeDiscriminant) + )); + } } diff --git a/src/builders/class.rs b/src/builders/class.rs index 378f53cd1..eb2ff6235 100644 --- a/src/builders/class.rs +++ b/src/builders/class.rs @@ -8,12 +8,13 @@ use crate::{ error::{Error, Result}, exception::PhpException, ffi::{ - zend_declare_class_constant, zend_declare_property, zend_do_implement_interface, - zend_register_internal_class_ex, zend_register_internal_interface, + zend_declare_class_constant, zend_declare_property, zend_declare_typed_property, + zend_do_implement_interface, zend_register_internal_class_ex, + zend_register_internal_interface, }, - flags::{ClassFlags, DataType, MethodFlags, PropertyFlags}, - types::{ZendClassObject, ZendObject, ZendStr, Zval}, - zend::{ClassEntry, ExecuteData, FunctionEntry}, + flags::{ClassFlags, MethodFlags, PropertyFlags}, + types::{PhpType, ZendClassObject, ZendObject, ZendStr, Zval}, + zend::{ClassEntry, ExecuteData, FunctionEntry, ZendType}, zend_fastcall, }; @@ -36,8 +37,10 @@ pub struct ClassProperty { pub default: PropertyDefault, /// Documentation comments. pub docs: DocComments, - /// PHP type for stub generation. - pub ty: Option, + /// PHP type for stub generation. Accepts a single + /// [`crate::flags::DataType`] (via [`PhpType::Simple`]) or a primitive + /// [`PhpType::Union`] for stubs like `public int|string $foo`. + pub ty: Option, /// Whether the property accepts null. pub nullable: bool, /// Whether the property is read-only (getter without setter). @@ -408,19 +411,7 @@ impl ClassBuilder { } for prop in self.properties { - let mut default_zval = match prop.default { - Some(f) => f()?, - None => Zval::new(), - }; - unsafe { - zend_declare_property( - class, - CString::new(prop.name.as_str())?.as_ptr(), - prop.name.len() as _, - &raw mut default_zval, - prop.flags.bits().try_into()?, - ); - } + register_property(class, prop)?; } for (name, value, _, _) in self.constants { @@ -449,8 +440,73 @@ impl ClassBuilder { } } +/// Registers a single property on the given class entry, dispatching to the +/// typed (`zend_declare_typed_property`) or untyped (`zend_declare_property`) +/// path based on whether [`ClassProperty::ty`] is set. +/// +/// For the typed path: when [`ClassProperty::default`] is absent the slot is +/// initialised with [`Zval::undef`] (`IS_UNDEF`) so the engine flags the +/// property as `IS_PROP_UNINIT` — same shape php-src `gen_stub.php` emits for +/// typed properties without an explicit default. The `zend_type` is built via +/// [`ZendType::empty_for_property`] which uses engine-managed allocations +/// (refcounted persistent strings, no `_ZEND_TYPE_ARENA_BIT`) so the engine +/// reclaims them at internal-class destroy without coordination from the +/// arg_info-side `cleanup_module_allocations` hook. +fn register_property(class: &mut ClassEntry, prop: ClassProperty) -> Result<()> { + let access_type: i32 = prop.flags.bits().try_into()?; + + if let Some(ty) = prop.ty.as_ref() { + let mut default_zval = match prop.default { + Some(f) => f()?, + None => Zval::undef(), + }; + + let zend_type = + ZendType::empty_for_property(ty, prop.nullable).ok_or(Error::InvalidCString)?; + + let name_zs = ZendStr::new(prop.name.as_str(), true).into_raw(); + + unsafe { + zend_declare_typed_property( + class, + name_zs, + &raw mut default_zval, + access_type, + ptr::null_mut(), + zend_type, + ); + // Match php-src `gen_stub.php` (every supported version): for + // persistent internal classes, `zend_declare_typed_property` + // copies + interns the name via + // `zend_new_interned_string(zend_string_copy(name))` and stores + // the copy in `property_info->name`. The caller-allocated + // refcounted string is unused after the call; release it here so + // each MINIT allocation pairs with a release rather than leaking + // one `zend_string` per typed property per re-init cycle. + crate::ffi::ext_php_rs_zend_string_release(name_zs); + } + } else { + let mut default_zval = match prop.default { + Some(f) => f()?, + None => Zval::new(), + }; + unsafe { + zend_declare_property( + class, + CString::new(prop.name.as_str())?.as_ptr(), + prop.name.len() as _, + &raw mut default_zval, + access_type, + ); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { + use crate::flags::DataType; use crate::test::test_function; use super::*; @@ -498,7 +554,7 @@ mod tests { flags: PropertyFlags::Public, default: None, docs: &["Doc 1"], - ty: Some(DataType::String), + ty: Some(DataType::String.into()), nullable: false, readonly: false, default_stub: None, @@ -508,7 +564,10 @@ mod tests { assert_eq!(class.properties[0].flags, PropertyFlags::Public); assert!(class.properties[0].default.is_none()); assert_eq!(class.properties[0].docs, &["Doc 1"] as DocComments); - assert_eq!(class.properties[0].ty, Some(DataType::String)); + assert_eq!( + class.properties[0].ty, + Some(PhpType::Simple(DataType::String)) + ); } #[test] @@ -559,5 +618,102 @@ mod tests { assert_eq!(class.docs, &["Doc 1"] as DocComments); } - // TODO: Test the register function + /// Property registration validation gates run before any FFI dispatch, + /// so they can be exercised against a zeroed `ClassEntry` (the same + /// zeroed-init pattern [`ClassBuilder::new`] already relies on for the + /// rest of the builder). Each test here picks an input that fails at a + /// distinct gate of [`register_property`] so a refactor that moves a + /// gate to the wrong side of the FFI call would surface as either a + /// missing `Err` here or a crash on the zeroed entry. + fn zeroed_class_entry() -> ClassEntry { + // SAFETY: `zend_class_entry` is `repr(C)` with no Drop impl. A + // zeroed value is never dereferenced by these tests because every + // input here trips a validation gate before the FFI call. + unsafe { MaybeUninit::zeroed().assume_init() } + } + + fn build_property(name: &str, ty: Option) -> ClassProperty { + ClassProperty { + name: name.into(), + flags: PropertyFlags::Public, + default: None, + docs: &[], + ty, + nullable: false, + readonly: false, + default_stub: None, + } + } + + #[test] + fn register_property_rejects_empty_class_union() { + let mut ce = zeroed_class_entry(); + let prop = build_property("p", Some(PhpType::ClassUnion(vec![]))); + + let result = register_property(&mut ce, prop); + assert!(matches!(result, Err(Error::InvalidCString))); + } + + #[test] + fn register_property_rejects_nul_in_class_name() { + let mut ce = zeroed_class_entry(); + let prop = build_property( + "p", + Some(PhpType::Simple(DataType::Object(Some("Foo\0Bar")))), + ); + + let result = register_property(&mut ce, prop); + assert!(matches!(result, Err(Error::InvalidCString))); + } + + #[test] + fn register_property_rejects_empty_simple_class_name() { + let mut ce = zeroed_class_entry(); + let prop = build_property("p", Some(PhpType::Simple(DataType::Object(Some(""))))); + + let result = register_property(&mut ce, prop); + assert!(matches!(result, Err(Error::InvalidCString))); + } + + #[test] + fn register_property_rejects_nul_in_class_union_member() { + let mut ce = zeroed_class_entry(); + let prop = build_property( + "p", + Some(PhpType::ClassUnion(vec!["Foo".into(), "Bar\0Baz".into()])), + ); + + let result = register_property(&mut ce, prop); + assert!(matches!(result, Err(Error::InvalidCString))); + } + + #[test] + fn register_property_rejects_nul_in_untyped_property_name() { + let mut ce = zeroed_class_entry(); + let prop = build_property("bad\0name", None); + + let result = register_property(&mut ce, prop); + assert!(matches!(result, Err(Error::InvalidCString))); + } + + #[cfg(not(php81))] + #[test] + fn register_property_rejects_intersection_pre_81() { + let mut ce = zeroed_class_entry(); + let prop = build_property( + "p", + Some(PhpType::Intersection(vec!["A".into(), "B".into()])), + ); + + let result = register_property(&mut ce, prop); + assert!( + matches!(result, Err(Error::InvalidCString)), + "intersection property should be rejected on PHP < 8.1", + ); + } + + // TODO: Happy-path coverage for `register_property` runs through the + // integration crate (`tests/src/integration/typed_property/`), since + // exercising the FFI call requires a fully-registered class entry inside + // an Embed run. } diff --git a/src/builders/enum_builder.rs b/src/builders/enum_builder.rs index 9f30a1240..ef7a9a3ea 100644 --- a/src/builders/enum_builder.rs +++ b/src/builders/enum_builder.rs @@ -106,7 +106,7 @@ impl EnumBuilder { let class = unsafe { zend_register_internal_enum( CString::new(self.name)?.as_ptr(), - self.datatype.as_u32().try_into()?, + crate::flags::data_type_as_u32(&self.datatype).try_into()?, methods.into_boxed_slice().as_ptr(), ) }; diff --git a/src/builders/function.rs b/src/builders/function.rs index 2285a5b69..c6b78ab7a 100644 --- a/src/builders/function.rs +++ b/src/builders/function.rs @@ -3,7 +3,7 @@ use crate::{ describe::DocComments, error::{Error, Result}, flags::{DataType, MethodFlags}, - types::Zval, + types::{PhpType, Zval}, zend::{ExecuteData, FunctionEntry, ZendType}, }; use std::{ffi::CString, mem, ptr}; @@ -30,7 +30,7 @@ pub struct FunctionBuilder<'a> { function: FunctionEntry, pub(crate) args: Vec>, n_req: Option, - pub(crate) retval: Option, + pub(crate) retval: Option, ret_as_ref: bool, pub(crate) ret_as_null: bool, pub(crate) docs: DocComments, @@ -130,15 +130,29 @@ impl<'a> FunctionBuilder<'a> { /// Sets the return value of the function. /// + /// Accepts a [`DataType`] for the simple case (via [`From for + /// PhpType`]) or a full [`PhpType`] for compound forms such as + /// [`PhpType::Union`]. + /// /// # Parameters /// - /// * `type_` - The return type of the function. + /// * `ty` - The return type of the function. /// * `as_ref` - Whether the function returns a reference. /// * `allow_null` - Whether the function return value is nullable. - pub fn returns(mut self, type_: DataType, as_ref: bool, allow_null: bool) -> Self { - self.retval = Some(type_); + pub fn returns>(mut self, ty: T, as_ref: bool, allow_null: bool) -> Self { + let ty = ty.into(); + // PHP rejects `?void` and `?mixed`, so the nullable flag is squashed + // for those single-type returns. Unions never resolve to those types + // syntactically, so the user's `allow_null` is honoured directly. + self.ret_as_null = match &ty { + PhpType::Simple(dt) => allow_null && *dt != DataType::Void && *dt != DataType::Mixed, + PhpType::Union(_) + | PhpType::ClassUnion(_) + | PhpType::Intersection(_) + | PhpType::Dnf(_) => allow_null, + }; + self.retval = Some(ty); self.ret_as_ref = as_ref; - self.ret_as_null = allow_null && type_ != DataType::Void && type_ != DataType::Mixed; self } @@ -181,11 +195,44 @@ impl<'a> FunctionBuilder<'a> { args.push(ArgInfo { // required_num_args name: n_req as *const _, - type_: match self.retval { - Some(retval) => { - ZendType::empty_from_type(retval, self.ret_as_ref, false, self.ret_as_null) + type_: match &self.retval { + Some(PhpType::Simple(dt)) => { + ZendType::empty_from_type(*dt, self.ret_as_ref, false, self.ret_as_null) .ok_or(Error::InvalidCString)? } + Some(PhpType::Union(types)) => ZendType::empty_from_primitive_union( + types, + self.ret_as_ref, + false, + self.ret_as_null, + ) + .ok_or(Error::InvalidCString)?, + Some(PhpType::ClassUnion(class_names)) => ZendType::empty_from_class_union( + class_names, + self.ret_as_ref, + false, + self.ret_as_null, + ) + .ok_or(Error::InvalidCString)?, + #[cfg(php83)] + Some(PhpType::Intersection(class_names)) => { + ZendType::empty_from_class_intersection( + class_names, + self.ret_as_ref, + false, + self.ret_as_null, + ) + .ok_or(Error::InvalidCString)? + } + #[cfg(not(php83))] + Some(PhpType::Intersection(_)) => return Err(Error::InvalidCString), + #[cfg(php83)] + Some(PhpType::Dnf(terms)) => { + ZendType::empty_from_dnf(terms, self.ret_as_ref, false, self.ret_as_null) + .ok_or(Error::InvalidCString)? + } + #[cfg(not(php83))] + Some(PhpType::Dnf(_)) => return Err(Error::InvalidCString), None => ZendType::empty(false, false), }, default_value: ptr::null(), @@ -206,3 +253,150 @@ impl<'a> FunctionBuilder<'a> { Ok(self.function) } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[cfg(php83)] + use crate::zend_fastcall; + + #[cfg(php83)] + zend_fastcall! { + extern "C" fn noop_handler(_: &mut ExecuteData, _: &mut Zval) {} + } + + #[test] + #[cfg(php83)] + fn returns_class_union_emits_literal_name_on_retval_arg_info() { + use crate::ffi::_ZEND_TYPE_LITERAL_NAME_BIT; + use std::ffi::CStr; + + let entry = FunctionBuilder::new("ret_class_union", noop_handler) + .returns( + PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]), + false, + false, + ) + .build() + .expect("class union return should build"); + + // arg_info[0] is the retval slot (zend_internal_function_info). + let retval_info = unsafe { &*entry.arg_info }; + assert_ne!(retval_info.type_.type_mask & _ZEND_TYPE_LITERAL_NAME_BIT, 0,); + assert!(!retval_info.type_.ptr.is_null()); + let class_str = unsafe { CStr::from_ptr(retval_info.type_.ptr.cast()) }; + assert_eq!(class_str.to_str().unwrap(), "Foo|Bar"); + } + + #[test] + #[cfg(php83)] + fn returns_class_union_with_allow_null_propagates_nullable_bit() { + use crate::ffi::_ZEND_TYPE_NULLABLE_BIT; + + let entry = FunctionBuilder::new("ret_nullable_class_union", noop_handler) + .returns( + PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]), + false, + true, + ) + .build() + .expect("nullable class union return should build"); + + let retval_info = unsafe { &*entry.arg_info }; + assert_ne!(retval_info.type_.type_mask & _ZEND_TYPE_NULLABLE_BIT, 0); + } + + #[test] + #[cfg(php83)] + fn returns_intersection_emits_list_with_intersection_bit_on_retval() { + use crate::ffi::{_ZEND_TYPE_INTERSECTION_BIT, _ZEND_TYPE_LIST_BIT}; + + let entry = FunctionBuilder::new("ret_intersection", noop_handler) + .returns( + PhpType::Intersection(vec!["Countable".to_owned(), "Traversable".to_owned()]), + false, + false, + ) + .build() + .expect("intersection return should build"); + + let retval_info = unsafe { &*entry.arg_info }; + assert_ne!(retval_info.type_.type_mask & _ZEND_TYPE_LIST_BIT, 0); + assert_ne!(retval_info.type_.type_mask & _ZEND_TYPE_INTERSECTION_BIT, 0); + assert!(!retval_info.type_.ptr.is_null()); + } + + #[test] + #[cfg(php83)] + fn returns_intersection_with_allow_null_errors() { + let result = FunctionBuilder::new("ret_nullable_intersection", noop_handler) + .returns( + PhpType::Intersection(vec!["Foo".to_owned(), "Bar".to_owned()]), + false, + true, + ) + .build(); + + assert!( + result.is_err(), + "nullable intersection retval must error: nullable form is the DNF (Foo&Bar)|null" + ); + } + + #[test] + #[cfg(php83)] + fn returns_dnf_emits_outer_list_with_union_bit_on_retval() { + use crate::ffi::{_ZEND_TYPE_LIST_BIT, _ZEND_TYPE_UNION_BIT}; + use crate::types::DnfTerm; + + let entry = FunctionBuilder::new("ret_dnf", noop_handler) + .returns( + PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]), + false, + false, + ) + .build() + .expect("DNF return should build"); + + let retval_info = unsafe { &*entry.arg_info }; + assert_ne!(retval_info.type_.type_mask & _ZEND_TYPE_LIST_BIT, 0); + assert_ne!(retval_info.type_.type_mask & _ZEND_TYPE_UNION_BIT, 0); + assert!(!retval_info.type_.ptr.is_null()); + } + + #[test] + #[cfg(php83)] + fn returns_dnf_with_allow_null_propagates_nullable_bit() { + use crate::ffi::_ZEND_TYPE_NULLABLE_BIT; + use crate::types::DnfTerm; + + let entry = FunctionBuilder::new("ret_nullable_dnf", noop_handler) + .returns( + PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]), + false, + true, + ) + .build() + .expect("nullable DNF return should build"); + + let retval_info = unsafe { &*entry.arg_info }; + assert_ne!(retval_info.type_.type_mask & _ZEND_TYPE_NULLABLE_BIT, 0); + } + + #[test] + #[cfg(php83)] + fn returns_empty_dnf_errors() { + let result = FunctionBuilder::new("ret_empty_dnf", noop_handler) + .returns(PhpType::Dnf(vec![]), false, false) + .build(); + assert!(result.is_err()); + } +} diff --git a/src/builders/module.rs b/src/builders/module.rs index 657910c18..80b5b1558 100644 --- a/src/builders/module.rs +++ b/src/builders/module.rs @@ -511,7 +511,7 @@ impl ModuleBuilder<'_> { flags: desc.flags, default: None, docs: desc.docs, - ty: Some(desc.ty), + ty: Some(desc.ty.into()), nullable: desc.nullable, readonly: desc.readonly, default_stub, diff --git a/src/convert.rs b/src/convert.rs index 57db0b430..829ea46dc 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -5,7 +5,7 @@ use crate::{ error::Result, exception::PhpException, flags::DataType, - types::{ZendObject, Zval}, + types::{PhpType, ZendObject, Zval}, }; /// Allows zvals to be converted into Rust types in a fallible way. Reciprocal @@ -14,6 +14,17 @@ pub trait FromZval<'a>: Sized { /// The corresponding type of the implemented value in PHP. const TYPE: DataType; + /// Returns the full PHP type expression for this value, used to register + /// arguments on `#[php_function]` and `#[php_impl]` signatures. + /// + /// The default wraps [`Self::TYPE`] as [`PhpType::Simple`]; compound types + /// such as [`crate::types::PhpUnion`]-derived enums override this to + /// return the actual union shape. + #[must_use] + fn php_type() -> PhpType { + PhpType::Simple(Self::TYPE) + } + /// Attempts to retrieve an instance of `Self` from a reference to a /// [`Zval`]. /// @@ -29,6 +40,10 @@ where { const TYPE: DataType = T::TYPE; + fn php_type() -> PhpType { + T::php_type() + } + fn from_zval(zval: &'a Zval) -> Option { Some(T::from_zval(zval)) } @@ -43,6 +58,17 @@ pub trait FromZvalMut<'a>: Sized { /// The corresponding type of the implemented value in PHP. const TYPE: DataType; + /// Returns the full PHP type expression for this value, used to register + /// arguments on `#[php_function]` and `#[php_impl]` signatures. + /// + /// The default wraps [`Self::TYPE`] as [`PhpType::Simple`]; types + /// implementing [`crate::types::PhpUnion`] override this to return the + /// actual union shape. + #[must_use] + fn php_type() -> PhpType { + PhpType::Simple(Self::TYPE) + } + /// Attempts to retrieve an instance of `Self` from a mutable reference to a /// [`Zval`]. /// @@ -58,6 +84,11 @@ where { const TYPE: DataType = ::TYPE; + #[inline] + fn php_type() -> PhpType { + ::php_type() + } + #[inline] fn from_zval_mut(zval: &'a mut Zval) -> Option { Self::from_zval(zval) @@ -143,6 +174,17 @@ pub trait IntoZval: Sized { /// Whether converting into a [`Zval`] may result in null. const NULLABLE: bool; + /// Returns the full PHP type expression for this value, used to register + /// return types on `#[php_function]` and `#[php_impl]` signatures. + /// + /// The default wraps [`Self::TYPE`] as [`PhpType::Simple`]; types + /// implementing [`crate::types::PhpUnion`] override this to return the + /// actual union shape. + #[must_use] + fn php_type() -> PhpType { + PhpType::Simple(Self::TYPE) + } + /// Converts a Rust primitive type into a Zval. Returns a result containing /// the Zval if successful. /// @@ -199,6 +241,10 @@ where const TYPE: DataType = T::TYPE; const NULLABLE: bool = true; + fn php_type() -> PhpType { + T::php_type() + } + #[inline] fn set_zval(self, zv: &mut Zval, persistent: bool) -> Result<()> { if let Some(val) = self { @@ -218,6 +264,10 @@ where const TYPE: DataType = T::TYPE; const NULLABLE: bool = T::NULLABLE; + fn php_type() -> PhpType { + T::php_type() + } + fn set_zval(self, zv: &mut Zval, persistent: bool) -> Result<()> { match self { Ok(val) => val.set_zval(zv, persistent), diff --git a/src/describe/mod.rs b/src/describe/mod.rs index 0a991f930..71919806b 100644 --- a/src/describe/mod.rs +++ b/src/describe/mod.rs @@ -9,6 +9,7 @@ use crate::{ constant::IntoConst, flags::{DataType, MethodFlags, PropertyFlags}, prelude::ModuleBuilder, + types::{DnfTerm, PhpType}, }; use abi::{Option, RString, Str, Vec}; @@ -133,6 +134,33 @@ pub struct Function { pub params: Vec, } +/// Converts a builder retval (`PhpType`) into the describe ABI shape. +/// +/// Nullability rules: +/// - `Simple(dt)` with `Mixed` is never nullable (PHP rejects `?mixed`). +/// - `Union(members)` is nullable if `ret_allow_null` is set OR `Null` +/// already appears in `members` (the two spellings produce the same +/// `_ZEND_TYPE_NULLABLE_BIT` at the Zend level). +fn retval_to_describe( + retval: std::option::Option, + ret_allow_null: bool, +) -> Option { + let Some(r) = retval else { + return Option::None; + }; + let nullable = match &r { + PhpType::Simple(dt) => *dt != DataType::Mixed && ret_allow_null, + PhpType::Union(members) => { + ret_allow_null || members.iter().any(|m| matches!(m, DataType::Null)) + } + PhpType::ClassUnion(_) | PhpType::Intersection(_) | PhpType::Dnf(_) => ret_allow_null, + }; + Option::Some(Retval { + ty: r.into(), + nullable, + }) +} + impl From> for Function { fn from(val: FunctionBuilder<'_>) -> Self { let ret_allow_null = val.ret_as_null; @@ -145,13 +173,7 @@ impl From> for Function { .collect::>() .into(), ), - ret: val - .retval - .map(|r| Retval { - ty: r, - nullable: r != DataType::Mixed && ret_allow_null, - }) - .into(), + ret: retval_to_describe(val.retval, ret_allow_null), params: val .args .into_iter() @@ -162,6 +184,87 @@ impl From> for Function { } } +/// ABI-stable mirror of [`DnfTerm`]. +/// +/// Carries class-name strings as [`RString`] so the `cargo-php` CLI can +/// read DNF terms across the FFI boundary. +#[repr(C, u8)] +#[derive(Debug, PartialEq)] +pub enum DnfTermAbi { + /// A single class name (the `C` in `(A&B)|C`). + Single(RString), + /// An intersection group of class/interface names (the `A&B`). + Intersection(Vec), +} + +/// ABI-stable representation of a PHP type expression. +/// +/// Mirrors [`crate::types::PhpType`] but uses the ABI-stable [`abi::Vec`] +/// wrapper so the `cargo-php` CLI can read this enum across the FFI +/// boundary without depending on Rust's unstable struct layout. +#[repr(C, u8)] +#[derive(Debug, PartialEq)] +pub enum PhpTypeAbi { + /// A single type, e.g. `int`, `string`, or a class name. + Simple(DataType), + /// A primitive union, e.g. `int|string` or `int|string|null`. + /// Members appear in the order the author declared them. + Union(Vec), + /// A class union, e.g. `Foo|Bar`. Members are class-name strings in + /// declaration order. Nullability is carried separately on `Parameter` + /// / `Retval` because PHP rejects the `?` shorthand on a union (the + /// rendered stub spells it `Foo|Bar|null`). + ClassUnion(Vec), + /// A class intersection, e.g. `Foo&Bar`. Members are class-name strings + /// in declaration order. Nullable intersections do not exist at this + /// layer: PHP cannot spell `?Foo&Bar`, and the equivalent DNF + /// `(Foo&Bar)|null` lives in [`PhpTypeAbi::Dnf`]. + Intersection(Vec), + /// Disjunctive Normal Form, e.g. `(A&B)|C`. Terms appear in declaration + /// order. Nullability is carried separately on `Parameter` / `Retval`; + /// the rendered stub spells nullables as `(A&B)|C|null`. + Dnf(Vec), +} + +impl From for PhpTypeAbi { + fn from(ty: PhpType) -> Self { + match ty { + PhpType::Simple(dt) => Self::Simple(dt), + PhpType::Union(members) => Self::Union(members.into()), + PhpType::ClassUnion(class_names) => Self::ClassUnion( + class_names + .into_iter() + .map(RString::from) + .collect::>() + .into(), + ), + PhpType::Intersection(class_names) => Self::Intersection( + class_names + .into_iter() + .map(RString::from) + .collect::>() + .into(), + ), + PhpType::Dnf(terms) => Self::Dnf( + terms + .into_iter() + .map(|t| match t { + DnfTerm::Single(name) => DnfTermAbi::Single(name.into()), + DnfTerm::Intersection(names) => DnfTermAbi::Intersection( + names + .into_iter() + .map(RString::from) + .collect::>() + .into(), + ), + }) + .collect::>() + .into(), + ), + } + } +} + /// Represents a parameter attached to an exported function or method. #[repr(C)] #[derive(Debug, PartialEq)] @@ -169,7 +272,7 @@ pub struct Parameter { /// Name of the parameter. pub name: RString, /// Type of the parameter. - pub ty: Option, + pub ty: Option, /// Whether the parameter is nullable. pub nullable: bool, /// Whether the parameter is variadic. @@ -218,14 +321,14 @@ impl Class { ty: MethodType::Member, params: vec![Parameter { name: "args".into(), - ty: Option::Some(DataType::Mixed), + ty: Option::Some(PhpTypeAbi::Simple(DataType::Mixed)), nullable: false, variadic: true, default: Option::None, }] .into(), retval: Option::Some(Retval { - ty: DataType::Mixed, + ty: PhpTypeAbi::Simple(DataType::Mixed), nullable: false, }), r#static: false, @@ -374,7 +477,7 @@ pub struct Property { /// Documentation comments for the property. pub docs: DocBlock, /// Type of the property. - pub ty: Option, + pub ty: Option, /// Visibility of the property. pub vis: Visibility, /// Whether the property is static. @@ -396,7 +499,7 @@ impl From for Property { Self { name: val.name.into(), docs, - ty: val.ty.into(), + ty: val.ty.map(PhpTypeAbi::from).into(), vis, static_, nullable: val.nullable, @@ -442,13 +545,7 @@ impl From<(FunctionBuilder<'_>, MethodFlags)> for Method { .collect::>() .into(), ), - retval: builder - .retval - .map(|r| Retval { - ty: r, - nullable: r != DataType::Mixed && ret_allow_null, - }) - .into(), + retval: retval_to_describe(builder.retval, ret_allow_null), params: builder .args .into_iter() @@ -468,7 +565,7 @@ impl From<(FunctionBuilder<'_>, MethodFlags)> for Method { #[derive(Debug, PartialEq)] pub struct Retval { /// Type of the return value. - pub ty: DataType, + pub ty: PhpTypeAbi, /// Whether the return value is nullable. pub nullable: bool, } @@ -573,7 +670,6 @@ impl From<(String, Box, DocComments)> for Constant { #[cfg(test)] mod tests { - #![cfg_attr(windows, feature(abi_vectorcall))] use cfg_if::cfg_if; use super::*; @@ -635,7 +731,7 @@ mod tests { function.params, vec![Parameter { name: "foo".into(), - ty: Option::Some(DataType::Long), + ty: Option::Some(PhpTypeAbi::Simple(DataType::Long)), nullable: false, variadic: false, default: Option::None, @@ -645,7 +741,7 @@ mod tests { assert_eq!( function.ret, Option::Some(Retval { - ty: DataType::Bool, + ty: PhpTypeAbi::Simple(DataType::Bool), nullable: true, }) ); @@ -718,7 +814,7 @@ mod tests { flags: PropertyFlags::Protected, default: None, docs: &["doc1", "doc2"], - ty: Some(DataType::String), + ty: Some(DataType::String.into()), nullable: true, readonly: false, default_stub: Some("null".into()), @@ -730,7 +826,10 @@ mod tests { assert!(!property.static_); assert!(property.nullable); assert_eq!(property.default, Option::Some("null".into())); - assert_eq!(property.ty, Option::Some(DataType::String)); + assert_eq!( + property.ty, + Option::Some(PhpTypeAbi::Simple(DataType::String)) + ); } #[test] @@ -746,7 +845,7 @@ mod tests { method.params, vec![Parameter { name: "foo".into(), - ty: Option::Some(DataType::Long), + ty: Option::Some(PhpTypeAbi::Simple(DataType::Long)), nullable: false, variadic: false, default: Option::None, @@ -756,7 +855,7 @@ mod tests { assert_eq!( method.retval, Option::Some(Retval { - ty: DataType::Bool, + ty: PhpTypeAbi::Simple(DataType::Bool), nullable: true, }) ); @@ -821,4 +920,356 @@ mod tests { let empty: Visibility = MethodFlags::empty().into(); assert_eq!(empty, Visibility::Public); } + + #[test] + fn php_type_simple_maps_to_phptypeabi_simple() { + let ty: PhpTypeAbi = PhpType::Simple(DataType::Long).into(); + match ty { + PhpTypeAbi::Simple(dt) => assert_eq!(dt, DataType::Long), + PhpTypeAbi::Union(_) + | PhpTypeAbi::ClassUnion(_) + | PhpTypeAbi::Intersection(_) + | PhpTypeAbi::Dnf(_) => { + panic!("expected Simple") + } + } + } + + #[test] + fn php_type_union_preserves_member_order() { + let ty: PhpTypeAbi = + PhpType::Union(vec![DataType::Long, DataType::String, DataType::Null]).into(); + match ty { + PhpTypeAbi::Union(members) => assert_eq!( + &*members, + &[DataType::Long, DataType::String, DataType::Null] + ), + PhpTypeAbi::Simple(_) + | PhpTypeAbi::ClassUnion(_) + | PhpTypeAbi::Intersection(_) + | PhpTypeAbi::Dnf(_) => { + panic!("expected Union") + } + } + } + + #[test] + fn php_type_class_union_preserves_member_order() { + let ty: PhpTypeAbi = PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]).into(); + match ty { + PhpTypeAbi::ClassUnion(members) => { + let names: StdVec<&str> = members.iter().map(AsRef::as_ref).collect(); + assert_eq!(names, &["Foo", "Bar"]); + } + PhpTypeAbi::Simple(_) + | PhpTypeAbi::Union(_) + | PhpTypeAbi::Intersection(_) + | PhpTypeAbi::Dnf(_) => { + panic!("expected ClassUnion") + } + } + } + + #[test] + fn php_type_intersection_preserves_member_order() { + let ty: PhpTypeAbi = + PhpType::Intersection(vec!["Countable".to_owned(), "Traversable".to_owned()]).into(); + match ty { + PhpTypeAbi::Intersection(members) => { + let names: StdVec<&str> = members.iter().map(AsRef::as_ref).collect(); + assert_eq!(names, &["Countable", "Traversable"]); + } + PhpTypeAbi::Simple(_) + | PhpTypeAbi::Union(_) + | PhpTypeAbi::ClassUnion(_) + | PhpTypeAbi::Dnf(_) => { + panic!("expected Intersection") + } + } + } + + #[test] + fn php_type_dnf_preserves_terms_and_order() { + let ty: PhpTypeAbi = PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]) + .into(); + match ty { + PhpTypeAbi::Dnf(terms) => { + assert_eq!(terms.len(), 2); + match &terms[0] { + DnfTermAbi::Intersection(names) => { + let s: StdVec<&str> = names.iter().map(AsRef::as_ref).collect(); + assert_eq!(s, &["A", "B"]); + } + DnfTermAbi::Single(_) => panic!("term 0 should be Intersection"), + } + match &terms[1] { + DnfTermAbi::Single(name) => assert_eq!(name.as_ref(), "C"), + DnfTermAbi::Intersection(_) => panic!("term 1 should be Single"), + } + } + PhpTypeAbi::Simple(_) + | PhpTypeAbi::Union(_) + | PhpTypeAbi::ClassUnion(_) + | PhpTypeAbi::Intersection(_) => panic!("expected Dnf"), + } + } + + #[test] + fn retval_to_describe_dnf_passes_through_allow_null() { + let r = retval_to_describe( + Some(PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ])), + true, + ); + match r { + Option::Some(retval) => { + assert!(retval.nullable, "DNF retval honours ret_allow_null flag"); + } + Option::None => panic!("DNF retval should be Some"), + } + } + + #[test] + fn parameter_from_arg_preserves_primitive_union() { + let arg = Arg::new("x", PhpType::Union(vec![DataType::Long, DataType::String])); + let p: Parameter = arg.into(); + assert_eq!( + p.ty, + Option::Some(PhpTypeAbi::Union( + vec![DataType::Long, DataType::String].into() + )) + ); + } + + #[test] + fn retval_to_describe_preserves_simple() { + let r = retval_to_describe(Some(PhpType::Simple(DataType::Long)), false); + assert_eq!( + r, + Option::Some(Retval { + ty: PhpTypeAbi::Simple(DataType::Long), + nullable: false, + }) + ); + } + + #[test] + fn retval_to_describe_preserves_union() { + let r = retval_to_describe( + Some(PhpType::Union(vec![DataType::Long, DataType::String])), + false, + ); + assert_eq!( + r, + Option::Some(Retval { + ty: PhpTypeAbi::Union(vec![DataType::Long, DataType::String].into()), + nullable: false, + }) + ); + } + + #[test] + fn retval_to_describe_nullable_union_via_member() { + let r = retval_to_describe( + Some(PhpType::Union(vec![ + DataType::Long, + DataType::String, + DataType::Null, + ])), + false, + ); + assert_eq!( + r, + Option::Some(Retval { + ty: PhpTypeAbi::Union( + vec![DataType::Long, DataType::String, DataType::Null].into() + ), + nullable: true, + }) + ); + } + + #[test] + fn retval_to_describe_nullable_union_via_flag() { + let r = retval_to_describe( + Some(PhpType::Union(vec![DataType::Long, DataType::String])), + true, + ); + assert_eq!( + r, + Option::Some(Retval { + ty: PhpTypeAbi::Union(vec![DataType::Long, DataType::String].into()), + nullable: true, + }) + ); + } + + fn build_union_module() -> Module { + let builder = ModuleBuilder::new("union_stubs", "0.0.0") + .function( + FunctionBuilder::new("u_int_or_string", crate::test::test_function) + .arg(Arg::new( + "v", + PhpType::Union(vec![DataType::Long, DataType::String]), + )) + .returns(DataType::Long, false, false), + ) + .function( + FunctionBuilder::new("u_int_string_or_null", crate::test::test_function) + .arg(Arg::new( + "v", + PhpType::Union(vec![DataType::Long, DataType::String, DataType::Null]), + )) + .returns(DataType::Long, false, false), + ) + .function( + FunctionBuilder::new("u_int_string_allow_null", crate::test::test_function) + .arg( + Arg::new("v", PhpType::Union(vec![DataType::Long, DataType::String])) + .allow_null(), + ) + .returns(DataType::Long, false, false), + ) + .function( + FunctionBuilder::new("u_returns_int_or_string", crate::test::test_function) + .returns( + PhpType::Union(vec![DataType::Long, DataType::String]), + false, + false, + ), + ) + .function( + FunctionBuilder::new("u_returns_int_string_or_null", crate::test::test_function) + .returns( + PhpType::Union(vec![DataType::Long, DataType::String, DataType::Null]), + false, + false, + ), + ); + builder.into() + } + + #[test] + #[allow(clippy::unwrap_used)] + fn stub_renders_primitive_union_param() { + let stub = build_union_module().to_stub().unwrap(); + assert!( + stub.contains("function u_int_or_string(int|string $v): int {}"), + "missing primitive union param: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn stub_renders_nullable_union_via_explicit_null_member() { + let stub = build_union_module().to_stub().unwrap(); + assert!( + stub.contains("function u_int_string_or_null(int|string|null $v): int {}"), + "missing union with explicit null member: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn stub_renders_nullable_union_via_allow_null_flag() { + let stub = build_union_module().to_stub().unwrap(); + assert!( + stub.contains("function u_int_string_allow_null(int|string|null $v"), + "missing union with allow_null flag: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn stub_renders_union_return_type() { + let stub = build_union_module().to_stub().unwrap(); + assert!( + stub.contains("function u_returns_int_or_string(): int|string {}"), + "missing union return type: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn stub_renders_nullable_union_return_type() { + let stub = build_union_module().to_stub().unwrap(); + assert!( + stub.contains("function u_returns_int_string_or_null(): int|string|null {}"), + "missing nullable union return type: {stub}" + ); + } + + #[test] + fn property_from_class_property_preserves_union() { + let cp = crate::builders::ClassProperty { + name: "x".into(), + flags: PropertyFlags::Public, + default: None, + docs: &[], + ty: Some(PhpType::Union(vec![DataType::Long, DataType::String])), + nullable: false, + readonly: false, + default_stub: None, + }; + let p: Property = cp.into(); + assert_eq!( + p.ty, + Option::Some(PhpTypeAbi::Union( + vec![DataType::Long, DataType::String].into() + )) + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn stub_renders_union_property_type() { + use crate::builders::ClassProperty; + let cp = ClassProperty { + name: "x".into(), + flags: PropertyFlags::Public, + default: None, + docs: &[], + ty: Some(PhpType::Union(vec![DataType::Long, DataType::String])), + nullable: false, + readonly: false, + default_stub: None, + }; + let p: Property = cp.into(); + let stub = p.to_stub().unwrap(); + assert!( + stub.contains("public int|string $x"), + "expected 'public int|string $x' in: {stub}" + ); + assert!( + !stub.contains("?int"), + "must not prefix union with `?`, got: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn stub_renders_nullable_union_property_via_flag() { + use crate::builders::ClassProperty; + let cp = ClassProperty { + name: "x".into(), + flags: PropertyFlags::Public, + default: None, + docs: &[], + ty: Some(PhpType::Union(vec![DataType::Long, DataType::String])), + nullable: true, + readonly: false, + default_stub: None, + }; + let p: Property = cp.into(); + let stub = p.to_stub().unwrap(); + assert!( + stub.contains("public int|string|null $x"), + "expected 'public int|string|null $x' in: {stub}" + ); + } } diff --git a/src/describe/stub.rs b/src/describe/stub.rs index 3e19cf380..c78c25fb4 100644 --- a/src/describe/stub.rs +++ b/src/describe/stub.rs @@ -9,8 +9,8 @@ use std::{ }; use super::{ - Class, Constant, DocBlock, Function, Method, MethodType, Module, Parameter, Property, Retval, - Visibility, + Class, Constant, DnfTermAbi, DocBlock, Function, Method, MethodType, Module, Parameter, + PhpTypeAbi, Property, Retval, Visibility, abi::{Option, RString, Str}, }; @@ -261,7 +261,7 @@ fn format_phpdoc( extract_php_type(type_override) } else { match ¶m.ty { - Option::Some(ty) => datatype_to_phpdoc(ty, param.nullable), + Option::Some(ty) => phptype_to_phpdoc(ty, param.nullable), Option::None => "mixed".to_string(), } }; @@ -276,7 +276,7 @@ fn format_phpdoc( // Output @return tag if let Some(retval) = ret { - let type_str = datatype_to_phpdoc(&retval.ty, retval.nullable); + let type_str = phptype_to_phpdoc(&retval.ty, retval.nullable); if let Some(desc) = &parsed.returns { writeln!(buf, " * @return {type_str} {desc}")?; } else { @@ -305,6 +305,66 @@ fn extract_php_type(type_str: &str) -> String { .to_string() } +/// Convert a `PhpTypeAbi` to `PHPDoc` type string. +fn phptype_to_phpdoc(ty: &PhpTypeAbi, nullable: bool) -> String { + match ty { + PhpTypeAbi::Simple(dt) => datatype_to_phpdoc(dt, nullable), + PhpTypeAbi::Union(members) => { + let parts: StdVec = members + .iter() + .map(|dt| datatype_to_phpdoc(dt, false)) + .collect(); + let mut s = parts.join("|"); + if nullable && !members.iter().any(|m| matches!(m, DataType::Null)) { + s.push_str("|null"); + } + s + } + PhpTypeAbi::ClassUnion(members) => { + let parts: StdVec = members + .iter() + .map(|name| format_class_type(name.as_ref(), false)) + .collect(); + let mut s = parts.join("|"); + if nullable { + s.push_str("|null"); + } + s + } + PhpTypeAbi::Intersection(members) => { + // Nullable intersections cannot be expressed in PHP user code; + // the legal form is the DNF `(Foo&Bar)|null`, which lives in + // [`PhpTypeAbi::Dnf`]. The `nullable` flag is intentionally + // ignored here. + let parts: StdVec = members + .iter() + .map(|name| format_class_type(name.as_ref(), false)) + .collect(); + parts.join("&") + } + PhpTypeAbi::Dnf(terms) => { + let parts: StdVec = terms + .iter() + .map(|term| match term { + DnfTermAbi::Single(name) => format_class_type(name.as_ref(), false), + DnfTermAbi::Intersection(members) => { + let inner: StdVec = members + .iter() + .map(|n| format_class_type(n.as_ref(), false)) + .collect(); + format!("({})", inner.join("&")) + } + }) + .collect(); + let mut s = parts.join("|"); + if nullable { + s.push_str("|null"); + } + s + } + } +} + /// Convert a `DataType` to `PHPDoc` type string. fn datatype_to_phpdoc(ty: &DataType, nullable: bool) -> String { let base = match ty { @@ -485,13 +545,7 @@ impl ToStub for Function { if let Option::Some(retval) = &self.ret { write!(buf, ": ")?; - // Don't add ? for mixed/null/void - they already include null or can't be nullable - if retval.nullable - && !matches!(retval.ty, DataType::Mixed | DataType::Null | DataType::Void) - { - write!(buf, "?")?; - } - retval.ty.fmt_stub(buf)?; + render_type_with_nullable(&retval.ty, retval.nullable, buf)?; } writeln!(buf, " {{}}") @@ -510,20 +564,19 @@ fn param_to_stub( // Check if we should use a type override from # Parameters section // Only use override if the param type is Mixed (i.e., Zval in Rust) - let type_override = type_overrides - .get(param.name.as_ref()) - .filter(|_| matches!(¶m.ty, Option::Some(DataType::Mixed) | Option::None)); + let type_override = type_overrides.get(param.name.as_ref()).filter(|_| { + matches!( + ¶m.ty, + Option::Some(PhpTypeAbi::Simple(DataType::Mixed)) | Option::None + ) + }); if let Some(override_str) = type_override { // Use the documented type from # Parameters let type_str = extract_php_type(override_str); write!(buf, "{type_str} ")?; } else if let Option::Some(ty) = ¶m.ty { - // Don't add ? for mixed/null/void - they already include null or can't be nullable - if param.nullable && !matches!(ty, DataType::Mixed | DataType::Null | DataType::Void) { - write!(buf, "?")?; - } - ty.fmt_stub(&mut buf)?; + render_type_with_nullable(ty, param.nullable, &mut buf)?; write!(buf, " ")?; } @@ -554,6 +607,122 @@ impl ToStub for Parameter { } } +impl ToStub for PhpTypeAbi { + fn fmt_stub(&self, buf: &mut String) -> FmtResult { + match self { + Self::Simple(dt) => dt.fmt_stub(buf), + Self::Union(members) => { + let mut first = true; + for dt in members.iter() { + if !first { + write!(buf, "|")?; + } + dt.fmt_stub(buf)?; + first = false; + } + Ok(()) + } + Self::ClassUnion(members) => { + let mut first = true; + for name in members.iter() { + if !first { + write!(buf, "|")?; + } + write_class_name(name.as_ref(), buf)?; + first = false; + } + Ok(()) + } + Self::Intersection(members) => { + let mut first = true; + for name in members.iter() { + if !first { + write!(buf, "&")?; + } + write_class_name(name.as_ref(), buf)?; + first = false; + } + Ok(()) + } + Self::Dnf(terms) => { + let mut first = true; + for term in terms.iter() { + if !first { + write!(buf, "|")?; + } + match term { + DnfTermAbi::Single(name) => write_class_name(name.as_ref(), buf)?, + DnfTermAbi::Intersection(members) => { + write!(buf, "(")?; + let mut inner_first = true; + for name in members.iter() { + if !inner_first { + write!(buf, "&")?; + } + write_class_name(name.as_ref(), buf)?; + inner_first = false; + } + write!(buf, ")")?; + } + } + first = false; + } + Ok(()) + } + } + } +} + +/// Writes a class name with a leading backslash (PHP FQCN form), unless one +/// is already present. Shared by the `ClassUnion`, `Intersection`, and +/// `Dnf` stub arms so a single rule governs every class-name rendering. +fn write_class_name(name: &str, buf: &mut String) -> FmtResult { + if name.starts_with('\\') { + write!(buf, "{name}") + } else { + write!(buf, "\\{name}") + } +} + +/// Render a `PhpTypeAbi` with the nullable flag honored. +/// +/// `Simple(dt)` uses the `?T` shorthand (except for `Mixed`/`Null`/`Void` +/// which are intrinsically non-nullable in PHP). `Union(members)` always +/// expands `null` as an explicit member with `|null` syntax (PHP rejects +/// `?` shorthand on union types). Already-nullable unions (`Null` member) +/// are not duplicated. `ClassUnion(members)` follows the same rule, except +/// members are class names so they cannot include `null` themselves: the +/// dedup check collapses to "always append `|null` when nullable". +/// `Intersection(_)` cannot be nullable in PHP user code (the legal form +/// `(Foo&Bar)|null` is the DNF), so the flag is ignored and the rendering +/// falls through to the plain `fmt_stub` output. `Dnf(_)` is always a +/// union: it appends `|null` (never `?` shorthand) when `nullable` is set. +fn render_type_with_nullable(ty: &PhpTypeAbi, nullable: bool, buf: &mut String) -> FmtResult { + match ty { + PhpTypeAbi::Simple(dt) => { + if nullable && !matches!(dt, DataType::Mixed | DataType::Null | DataType::Void) { + write!(buf, "?")?; + } + dt.fmt_stub(buf) + } + PhpTypeAbi::Union(members) => { + ty.fmt_stub(buf)?; + if nullable && !members.iter().any(|m| matches!(m, DataType::Null)) { + write!(buf, "|null")?; + } + Ok(()) + } + PhpTypeAbi::ClassUnion(_) | PhpTypeAbi::Dnf(_) => { + ty.fmt_stub(buf)?; + if nullable { + write!(buf, "|null")?; + } + Ok(()) + } + PhpTypeAbi::Intersection(_) => ty.fmt_stub(buf), + } +} + impl ToStub for DataType { fn fmt_stub(&self, buf: &mut String) -> FmtResult { let mut fqdn = "\\".to_owned(); @@ -733,7 +902,7 @@ impl ToStub for Property { } if let Option::Some(ty) = &self.ty { writeln!(buf, " *")?; - writeln!(buf, " * @var {}", datatype_to_phpdoc(ty, self.nullable))?; + writeln!(buf, " * @var {}", phptype_to_phpdoc(ty, self.nullable))?; } writeln!(buf, " */")?; } @@ -747,11 +916,7 @@ impl ToStub for Property { write!(buf, "readonly ")?; } if let Option::Some(ty) = &self.ty { - let nullable = self.nullable && !matches!(ty, DataType::Mixed | DataType::Null); - if nullable { - write!(buf, "?")?; - } - ty.fmt_stub(buf)?; + render_type_with_nullable(ty, self.nullable, buf)?; write!(buf, " ")?; } write!(buf, "${}", self.name)?; @@ -812,13 +977,7 @@ impl ToStub for Method { && let Option::Some(retval) = &self.retval { write!(buf, ": ")?; - // Don't add ? for mixed/null/void - they already include null or can't be nullable - if retval.nullable - && !matches!(retval.ty, DataType::Mixed | DataType::Null | DataType::Void) - { - write!(buf, "?")?; - } - retval.ty.fmt_stub(buf)?; + render_type_with_nullable(&retval.ty, retval.nullable, buf)?; } if self.r#abstract { @@ -955,7 +1114,7 @@ mod test { let prop = Property { name: "foo".into(), docs: super::DocBlock(vec![].into()), - ty: Option::Some(DataType::String), + ty: Option::Some(super::PhpTypeAbi::Simple(DataType::String)), vis: Visibility::Public, static_: false, nullable: false, @@ -976,7 +1135,7 @@ mod test { let prop = Property { name: "bar".into(), docs: super::DocBlock(vec![].into()), - ty: Option::Some(DataType::String), + ty: Option::Some(super::PhpTypeAbi::Simple(DataType::String)), vis: Visibility::Public, static_: false, nullable: true, @@ -998,7 +1157,7 @@ mod test { let prop = Property { name: "limit".into(), docs: super::DocBlock(vec![].into()), - ty: Option::Some(DataType::Long), + ty: Option::Some(super::PhpTypeAbi::Simple(DataType::Long)), vis: Visibility::Public, static_: true, nullable: false, @@ -1020,7 +1179,7 @@ mod test { let prop = Property { name: "label".into(), docs: super::DocBlock(vec![].into()), - ty: Option::Some(DataType::String), + ty: Option::Some(super::PhpTypeAbi::Simple(DataType::String)), vis: Visibility::Public, static_: true, nullable: false, @@ -1042,7 +1201,7 @@ mod test { let prop = Property { name: "bar".into(), docs: super::DocBlock(vec![" The user name.".into()].into()), - ty: Option::Some(DataType::String), + ty: Option::Some(super::PhpTypeAbi::Simple(DataType::String)), vis: Visibility::Public, static_: false, nullable: true, @@ -1090,7 +1249,7 @@ mod test { let prop = Property { name: "baz".into(), docs: super::DocBlock(vec![].into()), - ty: Option::Some(DataType::Array), + ty: Option::Some(super::PhpTypeAbi::Simple(DataType::Array)), vis: Visibility::Public, static_: false, nullable: false, @@ -1132,7 +1291,7 @@ mod test { let prop = Property { name: "count".into(), docs: super::DocBlock(vec![].into()), - ty: Option::Some(DataType::Long), + ty: Option::Some(super::PhpTypeAbi::Simple(DataType::Long)), vis: Visibility::Protected, static_: true, nullable: false, @@ -1154,7 +1313,7 @@ mod test { let prop = Property { name: "val".into(), docs: super::DocBlock(vec![].into()), - ty: Option::Some(DataType::Mixed), + ty: Option::Some(super::PhpTypeAbi::Simple(DataType::Mixed)), vis: Visibility::Public, static_: false, nullable: true, @@ -1174,7 +1333,9 @@ mod test { let prop = Property { name: "ref_".into(), docs: super::DocBlock(vec![" The related entity.".into()].into()), - ty: Option::Some(DataType::Object(Some("App\\Entity"))), + ty: Option::Some(super::PhpTypeAbi::Simple(DataType::Object(Some( + "App\\Entity", + )))), vis: Visibility::Private, static_: false, nullable: true, @@ -1260,7 +1421,7 @@ mod test { #[test] fn test_format_phpdoc() { - use super::{DocBlock, Parameter, Retval, Str, format_phpdoc}; + use super::{DocBlock, Parameter, PhpTypeAbi, Retval, Str, format_phpdoc}; use crate::describe::abi::Option; use crate::flags::DataType; @@ -1282,14 +1443,14 @@ mod test { let params = vec![Parameter { name: "name".into(), - ty: Option::Some(DataType::String), + ty: Option::Some(PhpTypeAbi::Simple(DataType::String)), nullable: false, variadic: false, default: Option::None, }]; let retval = Retval { - ty: DataType::String, + ty: PhpTypeAbi::Simple(DataType::String), nullable: false, }; @@ -1305,4 +1466,459 @@ mod test { assert!(!buf.contains("# Arguments")); assert!(!buf.contains("# Returns")); } + + #[test] + #[allow(clippy::unwrap_used)] + fn phptypeabi_simple_renders_as_datatype() { + use super::PhpTypeAbi; + assert_eq!(PhpTypeAbi::Simple(DataType::Long).to_stub().unwrap(), "int"); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn phptypeabi_union_renders_with_pipes() { + use super::PhpTypeAbi; + let ty = PhpTypeAbi::Union(vec![DataType::Long, DataType::String].into()); + assert_eq!(ty.to_stub().unwrap(), "int|string"); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn phptypeabi_class_union_renders_with_fqdn_pipes() { + use super::PhpTypeAbi; + let ty = PhpTypeAbi::ClassUnion(vec!["Foo".into(), "Bar".into()].into()); + assert_eq!(ty.to_stub().unwrap(), "\\Foo|\\Bar"); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn phptypeabi_intersection_renders_with_fqdn_amps() { + use super::PhpTypeAbi; + let ty = PhpTypeAbi::Intersection(vec!["Countable".into(), "Traversable".into()].into()); + assert_eq!(ty.to_stub().unwrap(), "\\Countable&\\Traversable"); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn render_type_with_nullable_ignores_flag_for_intersection() { + use super::PhpTypeAbi; + use super::render_type_with_nullable; + let ty = PhpTypeAbi::Intersection(vec!["A".into(), "B".into()].into()); + let mut buf = String::new(); + render_type_with_nullable(&ty, true, &mut buf).unwrap(); + assert_eq!(buf, "\\A&\\B"); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn function_with_union_param_renders_pipes() { + use super::{Function, PhpTypeAbi}; + use crate::describe::DocBlock; + use crate::describe::Parameter; + use crate::describe::abi::Option; + + let function = Function { + name: "foo".into(), + docs: DocBlock(vec![].into()), + ret: Option::None, + params: vec![Parameter { + name: "x".into(), + ty: Option::Some(PhpTypeAbi::Union( + vec![DataType::Long, DataType::String].into(), + )), + nullable: false, + variadic: false, + default: Option::None, + }] + .into(), + }; + + let stub = function.to_stub().unwrap(); + assert!( + stub.contains("function foo(int|string $x)"), + "expected 'function foo(int|string $x)' in: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn function_with_union_retval_renders_pipes() { + use super::{Function, PhpTypeAbi, Retval}; + use crate::describe::DocBlock; + use crate::describe::abi::Option; + + let function = Function { + name: "foo".into(), + docs: DocBlock(vec![].into()), + ret: Option::Some(Retval { + ty: PhpTypeAbi::Union(vec![DataType::Long, DataType::String].into()), + nullable: false, + }), + params: vec![].into(), + }; + + let stub = function.to_stub().unwrap(); + assert!( + stub.contains("): int|string {"), + "expected '): int|string {{' in: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn nullable_union_param_via_explicit_null_member() { + use super::{Function, PhpTypeAbi}; + use crate::describe::DocBlock; + use crate::describe::Parameter; + use crate::describe::abi::Option; + + let function = Function { + name: "foo".into(), + docs: DocBlock(vec![].into()), + ret: Option::None, + params: vec![Parameter { + name: "x".into(), + ty: Option::Some(PhpTypeAbi::Union( + vec![DataType::Long, DataType::String, DataType::Null].into(), + )), + nullable: false, + variadic: false, + default: Option::None, + }] + .into(), + }; + + let stub = function.to_stub().unwrap(); + assert!( + stub.contains("function foo(int|string|null $x"), + "expected 'int|string|null' (no `?` prefix, no duplicate null) in: {stub}" + ); + assert!( + !stub.contains("?int"), + "must not prefix union with `?`, got: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn nullable_union_param_via_flag() { + use super::{Function, PhpTypeAbi}; + use crate::describe::DocBlock; + use crate::describe::Parameter; + use crate::describe::abi::Option; + + let function = Function { + name: "foo".into(), + docs: DocBlock(vec![].into()), + ret: Option::None, + params: vec![Parameter { + name: "x".into(), + ty: Option::Some(PhpTypeAbi::Union( + vec![DataType::Long, DataType::String].into(), + )), + nullable: true, + variadic: false, + default: Option::None, + }] + .into(), + }; + + let stub = function.to_stub().unwrap(); + assert!( + stub.contains("function foo(int|string|null $x"), + "expected '|null' appended for nullable Union, got: {stub}" + ); + assert!( + !stub.contains("?int"), + "must not prefix union with `?`, got: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn nullable_union_retval_via_flag() { + use super::{Function, PhpTypeAbi, Retval}; + use crate::describe::DocBlock; + use crate::describe::abi::Option; + + let function = Function { + name: "foo".into(), + docs: DocBlock(vec![].into()), + ret: Option::Some(Retval { + ty: PhpTypeAbi::Union(vec![DataType::Long, DataType::String].into()), + nullable: true, + }), + params: vec![].into(), + }; + + let stub = function.to_stub().unwrap(); + assert!( + stub.contains("): int|string|null {"), + "expected '): int|string|null' in: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn nullable_union_does_not_duplicate_null_member() { + use super::{Function, PhpTypeAbi, Retval}; + use crate::describe::DocBlock; + use crate::describe::abi::Option; + + let function = Function { + name: "foo".into(), + docs: DocBlock(vec![].into()), + ret: Option::Some(Retval { + ty: PhpTypeAbi::Union( + vec![DataType::Long, DataType::String, DataType::Null].into(), + ), + nullable: true, + }), + params: vec![].into(), + }; + + let stub = function.to_stub().unwrap(); + assert!(stub.contains("): int|string|null {")); + assert!( + !stub.contains("null|null"), + "must not duplicate null when already a member, got: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn function_with_class_union_param_renders_fqdn_pipes() { + use super::{Function, PhpTypeAbi}; + use crate::describe::DocBlock; + use crate::describe::Parameter; + use crate::describe::abi::Option; + + let function = Function { + name: "foo".into(), + docs: DocBlock(vec![].into()), + ret: Option::None, + params: vec![Parameter { + name: "x".into(), + ty: Option::Some(PhpTypeAbi::ClassUnion( + vec!["Foo".into(), "Bar".into()].into(), + )), + nullable: false, + variadic: false, + default: Option::None, + }] + .into(), + }; + + let stub = function.to_stub().unwrap(); + assert!( + stub.contains("function foo(\\Foo|\\Bar $x)"), + "expected 'function foo(\\Foo|\\Bar $x)' in: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn function_with_class_union_retval_renders_fqdn_pipes() { + use super::{Function, PhpTypeAbi, Retval}; + use crate::describe::DocBlock; + use crate::describe::abi::Option; + + let function = Function { + name: "foo".into(), + docs: DocBlock(vec![].into()), + ret: Option::Some(Retval { + ty: PhpTypeAbi::ClassUnion(vec!["Foo".into(), "Bar".into()].into()), + nullable: false, + }), + params: vec![].into(), + }; + + let stub = function.to_stub().unwrap(); + assert!( + stub.contains("): \\Foo|\\Bar {"), + "expected '): \\Foo|\\Bar {{' in: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn nullable_class_union_appends_null_member() { + use super::{Function, PhpTypeAbi, Retval}; + use crate::describe::DocBlock; + use crate::describe::abi::Option; + + let function = Function { + name: "foo".into(), + docs: DocBlock(vec![].into()), + ret: Option::Some(Retval { + ty: PhpTypeAbi::ClassUnion(vec!["Foo".into(), "Bar".into()].into()), + nullable: true, + }), + params: vec![].into(), + }; + + let stub = function.to_stub().unwrap(); + assert!( + stub.contains("): \\Foo|\\Bar|null {"), + "expected '): \\Foo|\\Bar|null' in: {stub}" + ); + } + + fn dnf_a_and_b_or_c() -> super::PhpTypeAbi { + use super::{DnfTermAbi, PhpTypeAbi}; + PhpTypeAbi::Dnf( + vec![ + DnfTermAbi::Intersection(vec!["A".into(), "B".into()].into()), + DnfTermAbi::Single("C".into()), + ] + .into(), + ) + } + + #[test] + #[allow(clippy::unwrap_used)] + fn phptypeabi_dnf_renders_with_parens_and_pipes() { + let ty = dnf_a_and_b_or_c(); + assert_eq!(ty.to_stub().unwrap(), "(\\A&\\B)|\\C"); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn phptypeabi_dnf_two_intersections() { + use super::{DnfTermAbi, PhpTypeAbi}; + let ty = PhpTypeAbi::Dnf( + vec![ + DnfTermAbi::Intersection(vec!["A".into(), "B".into()].into()), + DnfTermAbi::Intersection(vec!["C".into(), "D".into()].into()), + ] + .into(), + ); + assert_eq!(ty.to_stub().unwrap(), "(\\A&\\B)|(\\C&\\D)"); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn phptypeabi_dnf_preserves_existing_backslash() { + use super::{DnfTermAbi, PhpTypeAbi}; + let ty = PhpTypeAbi::Dnf( + vec![ + DnfTermAbi::Intersection(vec!["\\Ns\\A".into(), "B".into()].into()), + DnfTermAbi::Single("\\Other\\C".into()), + ] + .into(), + ); + assert_eq!(ty.to_stub().unwrap(), "(\\Ns\\A&\\B)|\\Other\\C"); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn render_type_with_nullable_appends_pipe_null_for_dnf() { + use super::render_type_with_nullable; + let ty = dnf_a_and_b_or_c(); + let mut buf = String::new(); + render_type_with_nullable(&ty, true, &mut buf).unwrap(); + assert_eq!(buf, "(\\A&\\B)|\\C|null"); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn render_type_with_nullable_dnf_does_not_emit_question_mark() { + use super::render_type_with_nullable; + let ty = dnf_a_and_b_or_c(); + let mut buf = String::new(); + render_type_with_nullable(&ty, true, &mut buf).unwrap(); + assert!( + !buf.starts_with('?'), + "DNF must never use the `?` shorthand: {buf}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn function_with_dnf_param_renders_full_grammar() { + use super::Function; + use crate::describe::DocBlock; + use crate::describe::Parameter; + use crate::describe::abi::Option; + use crate::flags::DataType; + + let function = Function { + name: "foo".into(), + docs: DocBlock(vec![].into()), + ret: Option::Some(super::Retval { + ty: super::PhpTypeAbi::Simple(DataType::Long), + nullable: false, + }), + params: vec![Parameter { + name: "x".into(), + ty: Option::Some(dnf_a_and_b_or_c()), + nullable: false, + variadic: false, + default: Option::None, + }] + .into(), + }; + + let stub = function.to_stub().unwrap(); + assert!( + stub.contains("function foo((\\A&\\B)|\\C $x): int {}"), + "expected DNF param rendering in: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn function_with_dnf_retval_renders_full_grammar() { + use super::{Function, Retval}; + use crate::describe::DocBlock; + use crate::describe::abi::Option; + + let function = Function { + name: "foo".into(), + docs: DocBlock(vec![].into()), + ret: Option::Some(Retval { + ty: dnf_a_and_b_or_c(), + nullable: false, + }), + params: vec![].into(), + }; + + let stub = function.to_stub().unwrap(); + assert!( + stub.contains("): (\\A&\\B)|\\C {"), + "expected DNF retval rendering in: {stub}" + ); + } + + #[test] + #[allow(clippy::unwrap_used)] + fn nullable_dnf_retval_via_flag() { + use super::{Function, Retval}; + use crate::describe::DocBlock; + use crate::describe::abi::Option; + + let function = Function { + name: "foo".into(), + docs: DocBlock(vec![].into()), + ret: Option::Some(Retval { + ty: dnf_a_and_b_or_c(), + nullable: true, + }), + params: vec![].into(), + }; + + let stub = function.to_stub().unwrap(); + assert!( + stub.contains("): (\\A&\\B)|\\C|null {"), + "expected nullable DNF retval rendering in: {stub}" + ); + } + + #[test] + fn phptype_to_phpdoc_dnf_matches_stub_form() { + use super::phptype_to_phpdoc; + let ty = dnf_a_and_b_or_c(); + assert_eq!(phptype_to_phpdoc(&ty, false), "(\\A&\\B)|\\C"); + assert_eq!(phptype_to_phpdoc(&ty, true), "(\\A&\\B)|\\C|null"); + } } diff --git a/src/error.rs b/src/error.rs index ab1345d29..6477a248a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -74,6 +74,13 @@ pub enum Error { SapiWriteUnavailable, /// Failed to make an object lazy (PHP 8.4+) LazyObjectFailed, + /// The argument's PHP type has no equivalent in PHP's legacy + /// `Z_EXPECTED_*` discriminant enum (compound types, or scalar + /// `DataType` variants without a slot). For these arguments, format the + /// declared type via [`crate::args::Arg::ty`] and report the error + /// through `zend_argument_type_error` or [`crate::exception::PhpException`] + /// instead. + NoExpectedTypeDiscriminant, } impl Display for Error { @@ -123,6 +130,10 @@ impl Display for Error { Error::LazyObjectFailed => { write!(f, "Failed to make the object lazy") } + Error::NoExpectedTypeDiscriminant => write!( + f, + "Argument type has no PHP Z_EXPECTED_* discriminant; format Arg::ty() and use zend_argument_type_error or PhpException instead." + ), } } } diff --git a/src/ffi.rs b/src/ffi.rs index 566a03d80..fbbde8f94 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -21,6 +21,11 @@ unsafe extern "C" { pub fn ext_php_rs_zend_string_release(zs: *mut zend_string); pub fn ext_php_rs_is_known_valid_utf8(zs: *const zend_string) -> bool; pub fn ext_php_rs_set_known_valid_utf8(zs: *mut zend_string); + pub fn ext_php_rs_pemalloc_persistent(size: usize) -> *mut c_void; + pub fn ext_php_rs_zend_string_init_persistent_interned( + str_: *const c_char, + len: usize, + ) -> *mut zend_string; pub fn ext_php_rs_php_build_id() -> *const c_char; pub fn ext_php_rs_zend_object_alloc(obj_size: usize, ce: *mut zend_class_entry) -> *mut c_void; diff --git a/src/flags.rs b/src/flags.rs index 09ae620e1..bde0e7272 100644 --- a/src/flags.rs +++ b/src/flags.rs @@ -28,8 +28,6 @@ use crate::ffi::{ ZEND_HAS_STATIC_IN_METHODS, ZEND_INTERNAL_FUNCTION, ZEND_USER_FUNCTION, }; -use std::{convert::TryFrom, fmt::Display}; - use crate::error::{Error, Result}; bitflags! { @@ -361,202 +359,131 @@ impl From for FunctionType { } } -/// Valid data types for PHP. -#[repr(C, u8)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] -pub enum DataType { - /// Undefined - Undef, - /// `null` - Null, - /// `false` - False, - /// `true` - True, - /// Integer (the irony) - Long, - /// Floating point number - Double, - /// String - String, - /// Array - Array, - /// Iterable - Iterable, - /// Object - Object(Option<&'static str>), - /// Resource - Resource, - /// Reference - Reference, - /// Callable - Callable, - /// Constant expression - ConstantExpression, - /// Void - #[default] - Void, - /// Mixed - Mixed, - /// Boolean - Bool, - /// Pointer - Ptr, - /// Indirect (internal) - Indirect, -} - -impl DataType { - /// Returns the integer representation of the data type. - #[must_use] - pub const fn as_u32(&self) -> u32 { - match self { - DataType::Undef => IS_UNDEF, - DataType::Null => IS_NULL, - DataType::False => IS_FALSE, - DataType::True => IS_TRUE, - DataType::Long => IS_LONG, - DataType::Double => IS_DOUBLE, - DataType::String => IS_STRING, - DataType::Array => IS_ARRAY, - DataType::Object(_) => IS_OBJECT, - DataType::Resource | DataType::Reference => IS_RESOURCE, - DataType::Indirect => IS_INDIRECT, - DataType::Callable => IS_CALLABLE, - DataType::ConstantExpression => IS_CONSTANT_AST, - DataType::Void => IS_VOID, - DataType::Mixed => IS_MIXED, - DataType::Bool => _IS_BOOL, - DataType::Ptr => IS_PTR, - DataType::Iterable => IS_ITERABLE, - } +pub use ext_php_rs_types::DataType; + +/// Returns the raw zval type-tag for `dt`. +/// +/// Wraps the `IS_*` constants from `Zend/zend_types.h` so the rest of the +/// crate calls a typed helper instead of poking at FFI integers. Lives as a +/// free function in `crate::flags` rather than an inherent method on +/// [`DataType`] because [`DataType`] now lives in the +/// [`ext-php-rs-types`](https://crates.io/crates/ext-php-rs-types) workspace +/// member, where the FFI constants are not in scope. +#[must_use] +pub const fn data_type_as_u32(dt: &DataType) -> u32 { + match dt { + DataType::Undef => IS_UNDEF, + DataType::Null => IS_NULL, + DataType::False => IS_FALSE, + DataType::True => IS_TRUE, + DataType::Long => IS_LONG, + DataType::Double => IS_DOUBLE, + DataType::String => IS_STRING, + DataType::Array => IS_ARRAY, + DataType::Object(_) => IS_OBJECT, + DataType::Resource | DataType::Reference => IS_RESOURCE, + DataType::Indirect => IS_INDIRECT, + DataType::Callable => IS_CALLABLE, + DataType::ConstantExpression => IS_CONSTANT_AST, + DataType::Void => IS_VOID, + DataType::Mixed => IS_MIXED, + DataType::Bool => _IS_BOOL, + DataType::Ptr => IS_PTR, + DataType::Iterable => IS_ITERABLE, } } -// TODO: Ideally want something like this -// pub struct Type { -// data_type: DataType, -// is_refcounted: bool, -// is_collectable: bool, -// is_immutable: bool, -// is_persistent: bool, -// } -// -// impl From for Type { ... } - -impl TryFrom for DataType { - type Error = Error; - - fn try_from(value: ZvalTypeFlags) -> Result { - macro_rules! contains { - ($t: ident) => { - if value.contains(ZvalTypeFlags::$t) { - return Ok(DataType::$t); - } - }; - } - - contains!(Undef); - contains!(Null); - contains!(False); - contains!(True); - contains!(False); - contains!(Long); - contains!(Double); - contains!(String); - contains!(Array); - contains!(Resource); - contains!(Callable); - contains!(ConstantExpression); - contains!(Void); - - if value.contains(ZvalTypeFlags::Object) { - return Ok(DataType::Object(None)); - } - - Err(Error::UnknownDatatype(0)) +/// Decodes a raw zval type-tag (`IS_*` constant) into a [`DataType`]. +/// +/// Replaces the previous `impl From for DataType`; orphan rules block +/// that impl now that [`DataType`] lives in `ext-php-rs-types`. +#[must_use] +#[allow(clippy::bad_bit_mask)] +pub fn data_type_from_raw(value: u32) -> DataType { + macro_rules! contains { + ($c: ident, $t: ident) => { + if (value & $c) == $c { + return DataType::$t; + } + }; } -} -impl From for DataType { - #[allow(clippy::bad_bit_mask)] - fn from(value: u32) -> Self { - macro_rules! contains { - ($c: ident, $t: ident) => { - if (value & $c) == $c { - return DataType::$t; - } - }; - } + contains!(IS_VOID, Void); + contains!(IS_PTR, Ptr); + contains!(IS_INDIRECT, Indirect); + contains!(IS_CALLABLE, Callable); + contains!(IS_CONSTANT_AST, ConstantExpression); + contains!(IS_REFERENCE, Reference); + contains!(IS_RESOURCE, Resource); + contains!(IS_ARRAY, Array); + contains!(IS_STRING, String); + contains!(IS_DOUBLE, Double); + contains!(IS_LONG, Long); + contains!(IS_TRUE, True); + contains!(IS_FALSE, False); + contains!(IS_NULL, Null); + + if (value & IS_OBJECT) == IS_OBJECT { + return DataType::Object(None); + } - contains!(IS_VOID, Void); - contains!(IS_PTR, Ptr); - contains!(IS_INDIRECT, Indirect); - contains!(IS_CALLABLE, Callable); - contains!(IS_CONSTANT_AST, ConstantExpression); - contains!(IS_REFERENCE, Reference); - contains!(IS_RESOURCE, Resource); - contains!(IS_ARRAY, Array); - contains!(IS_STRING, String); - contains!(IS_DOUBLE, Double); - contains!(IS_LONG, Long); - contains!(IS_TRUE, True); - contains!(IS_FALSE, False); - contains!(IS_NULL, Null); - - if (value & IS_OBJECT) == IS_OBJECT { - return DataType::Object(None); - } + contains!(IS_UNDEF, Undef); - contains!(IS_UNDEF, Undef); + DataType::Mixed +} - DataType::Mixed +/// Maps a [`ZvalTypeFlags`] bag onto the first matching [`DataType`]. +/// +/// Replaces the previous `impl TryFrom for DataType`. +/// +/// # Errors +/// +/// Returns [`Error::UnknownDatatype`] when no flag matches a known PHP type. +pub fn data_type_try_from_zvf(value: ZvalTypeFlags) -> Result { + macro_rules! contains { + ($t: ident) => { + if value.contains(ZvalTypeFlags::$t) { + return Ok(DataType::$t); + } + }; } -} -impl Display for DataType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DataType::Undef => write!(f, "Undefined"), - DataType::Null => write!(f, "Null"), - DataType::False => write!(f, "False"), - DataType::True => write!(f, "True"), - DataType::Long => write!(f, "Long"), - DataType::Double => write!(f, "Double"), - DataType::String => write!(f, "String"), - DataType::Array => write!(f, "Array"), - DataType::Object(obj) => write!(f, "{}", obj.as_deref().unwrap_or("Object")), - DataType::Resource => write!(f, "Resource"), - DataType::Reference => write!(f, "Reference"), - DataType::Callable => write!(f, "Callable"), - DataType::ConstantExpression => write!(f, "Constant Expression"), - DataType::Void => write!(f, "Void"), - DataType::Bool => write!(f, "Bool"), - DataType::Mixed => write!(f, "Mixed"), - DataType::Ptr => write!(f, "Pointer"), - DataType::Indirect => write!(f, "Indirect"), - DataType::Iterable => write!(f, "Iterable"), - } + contains!(Undef); + contains!(Null); + contains!(False); + contains!(True); + contains!(False); + contains!(Long); + contains!(Double); + contains!(String); + contains!(Array); + contains!(Resource); + contains!(Callable); + contains!(ConstantExpression); + contains!(Void); + + if value.contains(ZvalTypeFlags::Object) { + return Ok(DataType::Object(None)); } + + Err(Error::UnknownDatatype(0)) } #[cfg(test)] mod tests { - #![allow(clippy::unnecessary_fallible_conversions)] - use super::DataType; + use super::{DataType, data_type_from_raw}; use crate::ffi::{ IS_ARRAY, IS_ARRAY_EX, IS_CONSTANT_AST, IS_CONSTANT_AST_EX, IS_DOUBLE, IS_FALSE, IS_INDIRECT, IS_INTERNED_STRING_EX, IS_LONG, IS_NULL, IS_OBJECT, IS_OBJECT_EX, IS_PTR, IS_REFERENCE, IS_REFERENCE_EX, IS_RESOURCE, IS_RESOURCE_EX, IS_STRING, IS_STRING_EX, IS_TRUE, IS_UNDEF, IS_VOID, }; - use std::convert::TryFrom; #[test] fn test_datatype() { macro_rules! test { ($c: ident, $t: ident) => { - assert_eq!(DataType::try_from($c), Ok(DataType::$t)); + assert_eq!(data_type_from_raw($c), DataType::$t); }; } @@ -568,7 +495,7 @@ mod tests { test!(IS_DOUBLE, Double); test!(IS_STRING, String); test!(IS_ARRAY, Array); - assert_eq!(DataType::try_from(IS_OBJECT), Ok(DataType::Object(None))); + assert_eq!(data_type_from_raw(IS_OBJECT), DataType::Object(None)); test!(IS_RESOURCE, Resource); test!(IS_REFERENCE, Reference); test!(IS_CONSTANT_AST, ConstantExpression); @@ -579,7 +506,7 @@ mod tests { test!(IS_INTERNED_STRING_EX, String); test!(IS_STRING_EX, String); test!(IS_ARRAY_EX, Array); - assert_eq!(DataType::try_from(IS_OBJECT_EX), Ok(DataType::Object(None))); + assert_eq!(data_type_from_raw(IS_OBJECT_EX), DataType::Object(None)); test!(IS_RESOURCE_EX, Resource); test!(IS_REFERENCE_EX, Reference); test!(IS_CONSTANT_AST_EX, ConstantExpression); diff --git a/src/lib.rs b/src/lib.rs index 32fa03e82..74e5f9e2d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -68,6 +68,7 @@ pub mod prelude { pub use crate::php_print; pub use crate::php_println; pub use crate::php_write; + pub use crate::types::PhpUnion; pub use crate::types::ZendCallable; #[cfg(feature = "observer")] pub use crate::zend::{ @@ -76,8 +77,8 @@ pub mod prelude { }; pub use crate::zend::{BailoutGuard, ModuleGlobal, ModuleGlobals}; pub use crate::{ - ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, php_impl_interface, - php_interface, php_module, wrap_constant, wrap_function, zend_fastcall, + PhpUnion, ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, + php_impl_interface, php_interface, php_module, wrap_constant, wrap_function, zend_fastcall, }; } @@ -108,6 +109,6 @@ pub const PHP_85: bool = cfg!(php85); #[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_impl_interface, - php_interface, php_module, wrap_constant, wrap_function, zend_fastcall, + PhpUnion, ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, + php_impl_interface, php_interface, php_module, wrap_constant, wrap_function, zend_fastcall, }; diff --git a/src/types/mod.rs b/src/types/mod.rs index f60f9c951..ada4eb8d5 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -11,6 +11,8 @@ mod iterator; mod long; mod object; mod php_ref; +mod php_type; +mod php_union; mod separated; mod string; mod zval; @@ -23,6 +25,8 @@ pub use iterator::ZendIterator; pub use long::ZendLong; pub use object::{PropertyQuery, ZendObject}; pub use php_ref::PhpRef; +pub use php_type::{DnfTerm, PhpType, PhpTypeParseError}; +pub use php_union::PhpUnion; pub use separated::Separated; pub use string::ZendStr; pub use zval::Zval; diff --git a/src/types/php_type.rs b/src/types/php_type.rs new file mode 100644 index 000000000..55ba6bc56 --- /dev/null +++ b/src/types/php_type.rs @@ -0,0 +1,11 @@ +//! Re-export shim for the type-string vocabulary. +//! +//! [`PhpType`], [`DnfTerm`], and [`PhpTypeParseError`] are defined in the +//! [`ext-php-rs-types`](https://crates.io/crates/ext-php-rs-types) workspace +//! member so the proc-macro crate can call the parser at expansion time +//! without re-introducing a dependency cycle on this runtime crate. +//! +//! User code keeps using `ext_php_rs::types::{PhpType, DnfTerm}`; this file +//! is the public address those names live at. + +pub use ext_php_rs_types::{DnfTerm, PhpType, PhpTypeParseError}; diff --git a/src/types/php_union.rs b/src/types/php_union.rs new file mode 100644 index 000000000..ae1524a44 --- /dev/null +++ b/src/types/php_union.rs @@ -0,0 +1,46 @@ +//! `PhpUnion` trait for Rust enums that map to PHP unions. +//! +//! [`PhpUnion`] is the runtime hook used by the +//! [`#[derive(PhpUnion)]`](ext_php_rs_derive::PhpUnion) macro to expose the +//! [`PhpType`] of a Rust enum whose variants newtype-wrap distinct PHP types. +//! Authors do not implement [`PhpUnion`] manually; the derive produces the +//! impl alongside [`IntoZval`](crate::convert::IntoZval) and +//! [`FromZval`](crate::convert::FromZval) so the enum can be used directly as +//! an `#[php_function]` parameter and return type. +//! +//! # Example +//! +//! ```rust,ignore +//! use ext_php_rs::types::PhpUnion; +//! use ext_php_rs::ZvalConvert; +//! +//! # use ext_php_rs::types::{PhpType}; +//! # use ext_php_rs::flags::DataType; +//! #[derive(ext_php_rs::PhpUnion)] +//! pub enum IntOrString { +//! Int(i64), +//! String(String), +//! } +//! +//! assert_eq!( +//! ::union_types(), +//! PhpType::Union(vec![DataType::Long, DataType::String]), +//! ); +//! ``` + +use crate::types::PhpType; + +/// A Rust enum whose variants newtype-wrap the members of a PHP union. +/// +/// Implemented by the [`#[derive(PhpUnion)]`](ext_php_rs_derive::PhpUnion) +/// macro. The function macro consults [`PhpUnion::union_types`] (via the +/// `php_type()` override on [`IntoZval`](crate::convert::IntoZval) / +/// [`FromZval`](crate::convert::FromZval)) to register the correct +/// [`PhpType::Union`] on the underlying [`Arg`](crate::args::Arg). +pub trait PhpUnion { + /// The [`PhpType`] this enum represents. + /// + /// For an enum whose variants wrap `i64` and `String`, this returns + /// `PhpType::Union(vec![DataType::Long, DataType::String])`. + fn union_types() -> PhpType; +} diff --git a/src/types/zval.rs b/src/types/zval.rs index 186925ada..bc05742d3 100644 --- a/src/types/zval.rs +++ b/src/types/zval.rs @@ -50,7 +50,7 @@ impl Zval { }, #[allow(clippy::used_underscore_items)] u1: _zval_struct__bindgen_ty_1 { - type_info: DataType::Null.as_u32(), + type_info: crate::flags::data_type_as_u32(&DataType::Null), }, #[allow(clippy::used_underscore_items)] u2: _zval_struct__bindgen_ty_2 { next: 0 }, @@ -65,6 +65,27 @@ impl Zval { zval } + /// Creates an `IS_UNDEF` zval (uninitialised marker). + /// + /// `IS_UNDEF` is what `zend_declare_typed_property` expects for a typed + /// property without an explicit default; the engine flags the slot as + /// `IS_PROP_UNINIT` so reads before the first assignment trigger the + /// standard "must not be accessed before initialization" error. Distinct + /// from [`Zval::new`], which produces `IS_NULL` and would be a type + /// violation on a non-nullable typed property. + #[must_use] + pub const fn undef() -> Self { + Self { + value: zend_value { + ptr: ptr::null_mut(), + }, + #[allow(clippy::used_underscore_items)] + u1: _zval_struct__bindgen_ty_1 { type_info: 0 }, + #[allow(clippy::used_underscore_items)] + u2: _zval_struct__bindgen_ty_2 { next: 0 }, + } + } + /// Creates a zval containing an empty array. #[must_use] pub fn new_array() -> Zval { @@ -477,7 +498,7 @@ impl Zval { /// Returns the type of the Zval. #[must_use] pub fn get_type(&self) -> DataType { - DataType::from(u32::from(unsafe { self.u1.v.type_ })) + crate::flags::data_type_from_raw(u32::from(unsafe { self.u1.v.type_ })) } /// Returns true if the zval is a long, false otherwise. diff --git a/src/wrapper.c b/src/wrapper.c index 5f295ff9a..6526c02be 100644 --- a/src/wrapper.c +++ b/src/wrapper.c @@ -16,6 +16,22 @@ void ext_php_rs_zend_string_release(zend_string *zs) { zend_string_release(zs); } +void *ext_php_rs_pemalloc_persistent(size_t size) { + return pemalloc(size, 1); +} + +/* Allocates a persistent zend_string and marks it as interned so Zend's + * `zend_string_release` becomes a no-op on it. Used by intersection type + * lists, whose entries we own across the engine's MSHUTDOWN cycle (Embed + * tests trigger startup/shutdown repeatedly and would otherwise free the + * list-owned strings out from under our cached function entries). */ +zend_string *ext_php_rs_zend_string_init_persistent_interned(const char *str, + size_t len) { + zend_string *zs = zend_string_init(str, len, 1); + GC_ADD_FLAGS(zs, IS_STR_INTERNED); + return zs; +} + bool ext_php_rs_is_known_valid_utf8(const zend_string *zs) { return GC_FLAGS(zs) & IS_STR_VALID_UTF8; } diff --git a/src/wrapper.h b/src/wrapper.h index c7e4fa24a..37d18d11b 100644 --- a/src/wrapper.h +++ b/src/wrapper.h @@ -49,6 +49,9 @@ void ext_php_rs_zend_string_release(zend_string *zs); bool ext_php_rs_is_known_valid_utf8(const zend_string *zs); void ext_php_rs_set_known_valid_utf8(zend_string *zs); +void *ext_php_rs_pemalloc_persistent(size_t size); +zend_string *ext_php_rs_zend_string_init_persistent_interned(const char *str, size_t len); + const char *ext_php_rs_php_build_id(); void *ext_php_rs_zend_object_alloc(size_t obj_size, zend_class_entry *ce); void ext_php_rs_zend_object_release(zend_object *obj); diff --git a/src/zend/_type.rs b/src/zend/_type.rs index 9dfa5c686..8797db451 100644 --- a/src/zend/_type.rs +++ b/src/zend/_type.rs @@ -1,5 +1,7 @@ use std::{ffi::c_void, ptr}; +#[cfg(php82)] +use crate::types::DnfTerm; use crate::{ ffi::{ _IS_BOOL, _ZEND_IS_VARIADIC_BIT, _ZEND_SEND_MODE_SHIFT, _ZEND_TYPE_NULLABLE_BIT, IS_MIXED, @@ -80,10 +82,7 @@ impl ZendType { is_variadic: bool, allow_null: bool, ) -> Option { - let mut flags = Self::arg_info_flags(pass_by_ref, is_variadic); - if allow_null { - flags |= _ZEND_TYPE_NULLABLE_BIT; - } + let mut flags = Self::arg_info_flags_with_nullable(pass_by_ref, is_variadic, allow_null); cfg_if::cfg_if! { if #[cfg(php83)] { flags |= crate::ffi::_ZEND_TYPE_LITERAL_NAME_BIT @@ -127,6 +126,654 @@ impl ZendType { } } + /// Builds a Zend type for a class union (e.g. `Foo|Bar`). + /// + /// Emits a single literal-name pointer (a NUL-terminated `CString` of + /// pipe-joined class names) and lets Zend itself split on `|`, intern + /// each member, and rewrite the outer mask into a `zend_type_list` plus + /// `_ZEND_TYPE_LIST_BIT | _ZEND_TYPE_UNION_BIT` at + /// `zend_register_functions` time. The same logic exists on every + /// supported PHP (`Zend/zend_API.c:2815-2855` on 8.1.0, `:2860-2895` on + /// 8.2.0, `:2929-2972` on 8.3+); only the literal-name bit's *name* + /// changes: + /// + /// - 8.1/8.2: `_ZEND_TYPE_NAME_BIT` itself doubles as the literal-name + /// bit (the engine reads `ptr` as `const char*`). + /// - 8.3+: a dedicated `_ZEND_TYPE_LITERAL_NAME_BIT` was introduced when + /// `_ZEND_TYPE_NAME_BIT` shifted to mean "already-interned + /// `zend_string*`". + /// + /// Mirrors the single-class path's strategy. The `CString` is reclaimed + /// in [`crate::zend::module::cleanup_module_allocations`]. + /// + /// Returns [`None`] if `class_names` is empty or any name has interior + /// NUL bytes. + /// + /// # Parameters + /// + /// * `class_names` - Class-name members of 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 the value can be null. + #[must_use] + pub fn empty_from_class_union( + class_names: &[String], + pass_by_ref: bool, + is_variadic: bool, + allow_null: bool, + ) -> Option { + if class_names.is_empty() { + return None; + } + + let mut type_mask = + Self::arg_info_flags_with_nullable(pass_by_ref, is_variadic, allow_null); + cfg_if::cfg_if! { + if #[cfg(php83)] { + type_mask |= crate::ffi::_ZEND_TYPE_LITERAL_NAME_BIT; + } else { + type_mask |= crate::ffi::_ZEND_TYPE_NAME_BIT; + } + } + + let joined = class_names.join("|"); + let ptr = std::ffi::CString::new(joined) + .ok()? + .into_raw() + .cast::(); + Some(Self { ptr, type_mask }) + } + + /// Builds a Zend type for a class intersection (e.g. `Foo&Bar`). + /// + /// Unlike [`Self::empty_from_class_union`], the literal-name shortcut + /// does NOT work for intersections. Verified across PHP 8.1.34, 8.2.30, + /// 8.3.30, 8.4.20, 8.5.5 and master: `zend_convert_internal_arg_info_type` + /// only ever splits on `|`. There is no `&` parsing path, so the engine + /// will never rewrite an `&`-joined literal name into an + /// `_ZEND_TYPE_INTERSECTION_BIT` list at registration time. + /// + /// Instead, this constructor hand-rolls the same shape `gen_stub.php` + /// emits for property/argument intersection types (see + /// `Zend/ext/zend_test/test_arginfo.h:1363-1370` in php-src): + /// + /// 1. Allocate a `zend_type_list` with `pemalloc(_, 1)` (via + /// `ext_php_rs_pemalloc_persistent`, which hides the file/line + /// parameters that vary between debug and release builds). + /// 2. For each class name, allocate a persistent `zend_string` tagged + /// with `IS_STR_INTERNED` (via + /// `ext_php_rs_zend_string_init_persistent_interned`). The interned + /// flag turns Zend's `zend_string_release` into a no-op so the + /// strings survive every MSHUTDOWN cycle, which matters because the + /// `#[php_module]` macro caches our function entries across embed + /// test re-init. Calling the real `zend_string_init_interned` + /// (function pointer wired up mid-startup) from `get_module()` + /// crashed the issue 02 first attempt; setting the flag on a plain + /// `zend_string_init` allocation has no lifecycle dependency. + /// 3. Populate each list entry with `_ZEND_TYPE_NAME_BIT` and the + /// just-allocated `zend_string*`. + /// 4. Set `_ZEND_TYPE_LIST_BIT | _ZEND_TYPE_INTERSECTION_BIT | + /// _ZEND_TYPE_ARENA_BIT` on the outer `type_mask`. The arena bit + /// tells Zend's `zend_type_release` (`Zend/zend_opcode.c:112-124`) + /// to skip the `pefree` of the list itself, leaving lifecycle to us + /// so the list survives the engine's startup/shutdown cycles too. + /// + /// Net effect: the list and its strings are persistently allocated + /// once during `get_module()` and live for the process lifetime. The + /// existing `_ZEND_TYPE_LIST_BIT` skip in + /// [`crate::zend::module::cleanup_module_allocations`] is already + /// correct: we leak the allocations on purpose (the leak is bounded — + /// one list + N strings per intersection type per module, freed by + /// the OS when the process exits). + /// + /// Returns [`None`] when: + /// + /// - `class_names` is empty, + /// - any class name has an interior NUL byte (NUL would terminate the C + /// string Zend later inspects), or + /// - `allow_null` is `true`. PHP user code cannot spell `?Foo&Bar`; the + /// only legal form is the DNF `(Foo&Bar)|null` which is the + /// responsibility of the future DNF representation. This constructor + /// refuses nullable intersections so callers fail early instead of + /// silently producing a half-built type. + /// + /// # Parameters + /// + /// * `class_names` - Class-name members of the intersection. + /// * `pass_by_ref` - Whether the value should be passed by reference. + /// * `is_variadic` - Whether this type represents a variadic argument. + /// * `allow_null` - Whether the value can be null. Must be `false`; + /// `true` returns [`None`] (the legal nullable form is the DNF + /// `(Foo&Bar)|null`, build a [`PhpType::Dnf`] for that). + /// + /// # Version constraint + /// + /// Available on PHP 8.3+ only. `ReflectionIntersectionType` was + /// introduced in PHP 8.1, but `zend_register_functions` on 8.1/8.2 + /// rejects pre-built `zend_type_list` for internal-function `arg_info` + /// (`Zend/zend_API.c` insists on `ZEND_TYPE_HAS_NAME` and re-parses + /// from a literal `const char*`; the engine only splits on `|`, not + /// `&`, so an `&`-joined literal name is not a viable encoding + /// either). 8.3+ added the `ZEND_TYPE_HAS_LITERAL_NAME` check that + /// leaves pre-built lists alone. + /// + /// [`PhpType::Dnf`]: crate::types::PhpType::Dnf + #[cfg(php83)] + #[must_use] + pub fn empty_from_class_intersection( + class_names: &[String], + pass_by_ref: bool, + is_variadic: bool, + allow_null: bool, + ) -> Option { + if class_names.is_empty() || allow_null { + return None; + } + + let list_ptr = build_class_list(class_names, true)?; + + let type_mask = Self::arg_info_flags(pass_by_ref, is_variadic) + | crate::ffi::_ZEND_TYPE_LIST_BIT + | crate::ffi::_ZEND_TYPE_INTERSECTION_BIT + | crate::ffi::_ZEND_TYPE_ARENA_BIT; + + Some(Self { + ptr: list_ptr.cast::(), + type_mask, + }) + } + + /// Builds a Zend type for a DNF (Disjunctive Normal Form) type + /// (e.g. `(A&B)|C`). PHP 8.2+. + /// + /// DNF is a top-level union whose alternatives may themselves be class + /// intersection groups. The on-disk shape mirrors what `zend_compile.c` + /// produces for `(A&B)|C`: + /// + /// 1. An outer [`zend_type_list`](crate::ffi::zend_type_list) with one + /// entry per [`DnfTerm`]. + /// 2. Each [`DnfTerm::Single`] becomes a list entry whose `ptr` is a + /// persistent-interned `zend_string*` and whose mask is + /// `_ZEND_TYPE_NAME_BIT`. + /// 3. Each [`DnfTerm::Intersection`] becomes a nested + /// [`zend_type_list`](crate::ffi::zend_type_list) (allocated and + /// populated identically to a flat + /// [`Self::empty_from_class_intersection`]); the corresponding outer + /// list entry's `ptr` points at that inner list and its mask carries + /// `_ZEND_TYPE_LIST_BIT | _ZEND_TYPE_INTERSECTION_BIT | + /// _ZEND_TYPE_ARENA_BIT`. + /// 4. The outer mask carries `_ZEND_TYPE_LIST_BIT | + /// _ZEND_TYPE_UNION_BIT | _ZEND_TYPE_ARENA_BIT`, plus + /// `_ZEND_TYPE_NULLABLE_BIT` when `allow_null` is set. + /// + /// The arena bit on every list (outer and inner) tells Zend's recursive + /// `zend_type_release` (`Zend/zend_opcode.c:112-124`) to skip the + /// `pefree` of our hand-allocations. Each `zend_string` is tagged + /// `IS_STR_INTERNED` by + /// [`crate::ffi::ext_php_rs_zend_string_init_persistent_interned`] so + /// `zend_string_release` becomes a no-op and the strings survive embed + /// MSHUTDOWN cycles. Lists and strings live for the process lifetime + /// (one allocation set per DNF arg/retval per module — bounded leak, + /// reclaimed at `DL_UNLOAD`). The + /// [`_ZEND_TYPE_LIST_BIT`](crate::ffi::_ZEND_TYPE_LIST_BIT) skip in + /// [`crate::zend::module::cleanup_module_allocations`] already covers + /// every level of this nested layout. + /// + /// Returns [`None`] when: + /// + /// - `terms` is empty, + /// - `terms.len() == 1` (degenerate; use [`PhpType::Simple`] for a + /// single class or [`PhpType::Intersection`] for a flat intersection), + /// - any [`DnfTerm::Intersection`] carries fewer than 2 members, + /// - any class name is empty or contains an interior NUL byte, or + /// - allocation fails. + /// + /// [`PhpType::Simple`]: crate::types::PhpType::Simple + /// [`PhpType::Intersection`]: crate::types::PhpType::Intersection + /// + /// # Parameters + /// + /// * `terms` - Class-side disjuncts in declaration order. + /// * `pass_by_ref` - Whether the value should be passed by reference. + /// * `is_variadic` - Whether this type represents a variadic argument. + /// * `allow_null` - Whether the value can be null. Threads the + /// `_ZEND_TYPE_NULLABLE_BIT` on the outer mask; this is the canonical + /// way to spell `(A&B)|null`. + /// + /// # Version constraint + /// + /// Available on PHP 8.3+ only. PHP 8.2 introduced DNF in user code but + /// its `zend_register_functions` does not accept pre-built + /// `zend_type_list` for internal-function `arg_info` (same root cause as + /// [`Self::empty_from_class_intersection`]); the engine only began + /// honouring `_ZEND_TYPE_LIST_BIT` here in 8.3+ via the + /// `ZEND_TYPE_HAS_LITERAL_NAME` gate. + #[cfg(php83)] + #[must_use] + pub fn empty_from_dnf( + terms: &[DnfTerm], + pass_by_ref: bool, + is_variadic: bool, + allow_null: bool, + ) -> Option { + if terms.len() < 2 { + // Empty or single-term DNF is degenerate — callers should pick + // the more specific variant (Simple, ClassUnion, Intersection) + // explicitly. Refusing here keeps a single canonical spelling + // per legal PHP type. + return None; + } + + for term in terms { + if !dnf_term_is_valid(term) { + return None; + } + } + + let num_terms = u32::try_from(terms.len()).ok()?; + + let outer_size = std::mem::size_of::() + + (terms.len().saturating_sub(1)) * std::mem::size_of::(); + + // SAFETY: pemalloc(_, 1). Arena bit on the outer mask below tells + // Zend's `zend_type_release` to skip the `pefree` of this list, so + // the allocation lives for the process lifetime. + let outer_list = unsafe { crate::ffi::ext_php_rs_pemalloc_persistent(outer_size) } + .cast::(); + + if outer_list.is_null() { + return None; + } + + // SAFETY: `outer_list` points to a freshly-allocated + // `zend_type_list` with capacity for `num_terms` entries. + unsafe { + (*outer_list).num_types = num_terms; + } + + for (i, term) in terms.iter().enumerate() { + let entry = match term { + DnfTerm::Single(name) => { + let s = unsafe { + crate::ffi::ext_php_rs_zend_string_init_persistent_interned( + name.as_ptr().cast::(), + name.len(), + ) + }; + if s.is_null() { + return None; + } + zend_type { + ptr: s.cast::(), + type_mask: crate::ffi::_ZEND_TYPE_NAME_BIT, + } + } + DnfTerm::Intersection(names) => { + let inner_list = build_class_list(names, true)?; + zend_type { + ptr: inner_list.cast::(), + type_mask: crate::ffi::_ZEND_TYPE_LIST_BIT + | crate::ffi::_ZEND_TYPE_INTERSECTION_BIT + | crate::ffi::_ZEND_TYPE_ARENA_BIT, + } + } + }; + + // SAFETY: `types` is a flexible array; index `i` is within the + // freshly-allocated capacity (`num_terms` entries). + unsafe { + let slot = (*outer_list).types.as_mut_ptr().add(i); + *slot = entry; + } + } + + let type_mask = Self::arg_info_flags_with_nullable(pass_by_ref, is_variadic, allow_null) + | crate::ffi::_ZEND_TYPE_LIST_BIT + | crate::ffi::_ZEND_TYPE_UNION_BIT + | crate::ffi::_ZEND_TYPE_ARENA_BIT; + + Some(Self { + ptr: outer_list.cast::(), + type_mask, + }) + } + + /// Builds a Zend type for a primitive union (e.g. `int|string`). + /// + /// PHP encodes pure primitive unions as a single [`zend_type`] whose + /// `type_mask` ORs together the `MAY_BE_*` bits of every member; no + /// `zend_type_list` is needed. The runtime fast-path + /// (`zend_check_type` -> `ZEND_TYPE_CONTAINS_CODE`) reads exactly that + /// outer mask. Lists become necessary only when class types enter the + /// picture, which is handled by later additions. + /// + /// Returns [`None`] if `types` is empty (a union with zero members is + /// malformed). Callers should pass at least two distinct member types; + /// a single-member input is accepted but is semantically equivalent to + /// [`Self::empty_from_type`]. + /// + /// # Parameters + /// + /// * `types` - Member types of 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 the value can be null. + #[must_use] + pub fn empty_from_primitive_union( + types: &[DataType], + pass_by_ref: bool, + is_variadic: bool, + allow_null: bool, + ) -> Option { + if types.is_empty() { + return None; + } + + let mut type_mask = + Self::arg_info_flags_with_nullable(pass_by_ref, is_variadic, allow_null); + for dt in types { + type_mask |= primitive_may_be(*dt); + } + + Some(Self { + ptr: ptr::null_mut(), + type_mask, + }) + } + + /// Builds a Zend type suitable for `zend_declare_typed_property`. + /// + /// Property registration is structurally distinct from `arg_info` on every + /// supported PHP version: `zend_declare_typed_property` stores the + /// `zend_type` verbatim, with no `zend_register_functions`-style literal + /// name preprocessing. Class names must therefore reach the engine as + /// `zend_string*` (not `const char*` literals), and class unions must be + /// pre-built `zend_type_list`s instead of pipe-joined literals. + /// php-src's own `gen_stub.php` emits this shape on every supported + /// version (`build/gen_stub.php` 8.1 line 1450, 8.2 line 2194, master + /// line 2419). + /// + /// Lifecycle: every allocation here is engine-managed. Strings are + /// refcounted persistent (no `IS_STR_INTERNED`); `zend_type_list`s carry + /// no `_ZEND_TYPE_ARENA_BIT`. At internal-class destroy (MSHUTDOWN), the + /// engine's `zend_type_release` (`Zend/zend_opcode.c:112-124`) walks the + /// shape and `pefree`s the list + `zend_string_release`s each entry. + /// Mirrors the per-MINIT allocation rhythm: every cycle re-builds the + /// shape against a fresh class entry, every MSHUTDOWN releases it. No + /// accumulating leak in embed tests; no `cleanup_module_allocations` + /// involvement (that hook is `arg_info`-only). + /// + /// # Version constraints + /// + /// - `Simple` (primitive or class), `Union`, `ClassUnion`: every + /// supported version. Properties accept these on 8.0+; the engine + /// surface for typed properties exists since the language feature + /// landed. + /// - `Intersection`: PHP 8.1+ (language minimum). Returns [`None`] on + /// earlier versions. + /// - `Dnf`: PHP 8.3+ (the language feature is 8.2 but + /// `zend_declare_typed_property` only accepts the nested intersection + /// terms from 8.3 onwards). Returns [`None`] on earlier versions. + /// + /// Differs from the `arg_info` `cfg(php83)` gate on intersection / DNF: + /// `zend_declare_typed_property` accepts pre-built `zend_type_list`s on + /// every version that supports the language feature, whereas + /// `zend_register_functions` did not until 8.3. + /// + /// # Returns + /// + /// [`None`] when: + /// + /// - any class name is empty or contains an interior NUL byte, + /// - allocation fails, + /// - the variant is `Intersection` on PHP < 8.1 or `Dnf` on PHP < 8.3, + /// - the variant is empty (e.g. `ClassUnion(vec![])`), + /// - the variant is structurally degenerate per its constructor's rules + /// (e.g. single-term DNF — see [`Self::empty_from_dnf`] for the + /// canonical-spelling rationale, mirrored here for property symmetry). + /// + /// # Parameters + /// + /// * `ty` - The PHP type to build for. + /// * `allow_null` - Whether the property accepts `null`. Combined with + /// the type's nullability rules. + #[must_use] + pub fn empty_for_property(ty: &crate::types::PhpType, allow_null: bool) -> Option { + use crate::types::PhpType; + + match ty { + PhpType::Simple(DataType::Object(Some(class))) => { + Self::empty_from_class_for_property(class, allow_null) + } + PhpType::Simple(dt) => Some(Self { + ptr: ptr::null_mut(), + type_mask: Self::type_init_code(*dt, false, false, allow_null), + }), + PhpType::Union(members) => { + let mut type_mask = if allow_null { + _ZEND_TYPE_NULLABLE_BIT + } else { + 0 + }; + for dt in members { + type_mask |= primitive_may_be(*dt); + } + Some(Self { + ptr: ptr::null_mut(), + type_mask, + }) + } + PhpType::ClassUnion(class_names) => { + Self::empty_from_class_union_for_property(class_names, allow_null) + } + PhpType::Intersection(class_names) => { + Self::empty_from_class_intersection_for_property(class_names, allow_null) + } + PhpType::Dnf(terms) => Self::empty_from_dnf_for_property(terms, allow_null), + } + } + + /// Property-side single class builder. Emits a `zend_string*`-bearing + /// `zend_type` (mask = `_ZEND_TYPE_NAME_BIT [| _ZEND_TYPE_NULLABLE_BIT]`) + /// instead of the literal-name shape used by [`Self::empty_from_class_type`] + /// for `arg_info`, because `zend_declare_typed_property` does no + /// literal-name preprocessing on any version. + /// + /// The string is allocated via + /// [`crate::ffi::ext_php_rs_zend_string_init`] with `persistent = true`, + /// so the engine takes ownership and refcount-releases it at + /// internal-class destroy. + /// + /// Returns [`None`] on empty / interior-NUL class name or allocation + /// failure. + fn empty_from_class_for_property(class_name: &str, allow_null: bool) -> Option { + if class_name.is_empty() || class_name.as_bytes().contains(&0u8) { + return None; + } + + let str_ptr = unsafe { + crate::ffi::ext_php_rs_zend_string_init( + class_name.as_ptr().cast::(), + class_name.len(), + true, + ) + }; + if str_ptr.is_null() { + return None; + } + + let mut type_mask = crate::ffi::_ZEND_TYPE_NAME_BIT; + if allow_null { + type_mask |= _ZEND_TYPE_NULLABLE_BIT; + } + + Some(Self { + ptr: str_ptr.cast::(), + type_mask, + }) + } + + /// Property-side class union builder. Allocates a real `zend_type_list` + /// with one `_ZEND_TYPE_NAME_BIT` + `zend_string*` entry per member, then + /// wraps it with `_ZEND_TYPE_LIST_BIT | _ZEND_TYPE_UNION_BIT [| + /// _ZEND_TYPE_NULLABLE_BIT]`. No arena bit — the engine `pefree`s the + /// list at internal-class destroy. + /// + /// Mirrors `gen_stub.php`'s property emission for `Foo|Bar`: + /// `ZEND_TYPE_INIT_UNION(, MAY_BE_NULL?)`. + fn empty_from_class_union_for_property( + class_names: &[String], + allow_null: bool, + ) -> Option { + if class_names.is_empty() { + return None; + } + + let list_ptr = build_class_list(class_names, false)?; + + let mut type_mask = crate::ffi::_ZEND_TYPE_LIST_BIT | crate::ffi::_ZEND_TYPE_UNION_BIT; + if allow_null { + type_mask |= _ZEND_TYPE_NULLABLE_BIT; + } + + Some(Self { + ptr: list_ptr.cast::(), + type_mask, + }) + } + + /// Property-side class intersection builder (PHP 8.1+). + /// + /// Same shape as the `arg_info` intersection but without the `_ZEND_TYPE_ARENA_BIT` + /// (engine reclaims the list) and with non-interned strings (engine refcount-releases). + /// `allow_null` is rejected; nullable intersections must be expressed as + /// `(A&B)|null` via [`PhpType::Dnf`](crate::types::PhpType::Dnf). + #[cfg(php81)] + fn empty_from_class_intersection_for_property( + class_names: &[String], + allow_null: bool, + ) -> Option { + if class_names.is_empty() || allow_null { + return None; + } + + let list_ptr = build_class_list(class_names, false)?; + + let type_mask = crate::ffi::_ZEND_TYPE_LIST_BIT | crate::ffi::_ZEND_TYPE_INTERSECTION_BIT; + + Some(Self { + ptr: list_ptr.cast::(), + type_mask, + }) + } + + /// Property-side intersection on pre-8.1 returns `None`. + #[cfg(not(php81))] + fn empty_from_class_intersection_for_property( + _class_names: &[String], + _allow_null: bool, + ) -> Option { + None + } + + /// Property-side DNF builder (PHP 8.3+). + /// + /// Same nested-list shape as the `arg_info` DNF but without `_ZEND_TYPE_ARENA_BIT` + /// at every level (the engine's recursive `zend_type_release` frees the + /// inner intersection lists), and with non-interned strings. Gated at + /// PHP 8.3 because 8.2's `zend_declare_typed_property` iterates the type + /// list with `ZEND_ASSERT(!ZEND_TYPE_HAS_LIST(*single_type))`, rejecting + /// the nested intersection terms a DNF embeds. PHP 8.3 dropped that + /// assertion in favour of `zend_normalize_internal_type`. + #[cfg(php83)] + fn empty_from_dnf_for_property(terms: &[DnfTerm], allow_null: bool) -> Option { + if terms.len() < 2 { + return None; + } + + for term in terms { + if !dnf_term_is_valid(term) { + return None; + } + } + + let num_terms = u32::try_from(terms.len()).ok()?; + + let outer_size = std::mem::size_of::() + + (terms.len().saturating_sub(1)) * std::mem::size_of::(); + + // SAFETY: pemalloc(_, 1). No arena bit on the outer mask below, so + // Zend's `zend_type_release` will `pefree` this list at internal-class + // destroy. + let outer_list = unsafe { crate::ffi::ext_php_rs_pemalloc_persistent(outer_size) } + .cast::(); + + if outer_list.is_null() { + return None; + } + + // SAFETY: `outer_list` points to a freshly-allocated `zend_type_list` + // with capacity for `num_terms` entries. + unsafe { + (*outer_list).num_types = num_terms; + } + + for (i, term) in terms.iter().enumerate() { + let entry = match term { + DnfTerm::Single(name) => { + let s = unsafe { + crate::ffi::ext_php_rs_zend_string_init( + name.as_ptr().cast::(), + name.len(), + true, + ) + }; + if s.is_null() { + return None; + } + zend_type { + ptr: s.cast::(), + type_mask: crate::ffi::_ZEND_TYPE_NAME_BIT, + } + } + DnfTerm::Intersection(names) => { + let inner_list = build_class_list(names, false)?; + zend_type { + ptr: inner_list.cast::(), + type_mask: crate::ffi::_ZEND_TYPE_LIST_BIT + | crate::ffi::_ZEND_TYPE_INTERSECTION_BIT, + } + } + }; + + // SAFETY: `types` is a flexible array; index `i` is within the + // freshly-allocated capacity (`num_terms` entries). + unsafe { + let slot = (*outer_list).types.as_mut_ptr().add(i); + *slot = entry; + } + } + + let mut type_mask = crate::ffi::_ZEND_TYPE_LIST_BIT | crate::ffi::_ZEND_TYPE_UNION_BIT; + if allow_null { + type_mask |= _ZEND_TYPE_NULLABLE_BIT; + } + + Some(Self { + ptr: outer_list.cast::(), + type_mask, + }) + } + + /// Property-side DNF on pre-8.3 returns `None`. + #[cfg(not(php83))] + fn empty_from_dnf_for_property( + _terms: &[crate::types::DnfTerm], + _allow_null: bool, + ) -> Option { + None + } + /// Calculates the internal flags of the type. /// Translation of of the `_ZEND_ARG_INFO_FLAGS` macro from /// `zend_API.h:110`. @@ -144,6 +791,23 @@ impl ZendType { }) } + /// Like [`Self::arg_info_flags`] but also threads `_ZEND_TYPE_NULLABLE_BIT` + /// when `allow_null` is set. Centralises the pattern shared by every + /// list/string-bearing constructor (single class, primitive union, class + /// union); primitive scalars take a different shape via + /// [`Self::type_init_code`]. + pub(crate) fn arg_info_flags_with_nullable( + pass_by_ref: bool, + is_variadic: bool, + allow_null: bool, + ) -> u32 { + let mut flags = Self::arg_info_flags(pass_by_ref, is_variadic); + if allow_null { + flags |= _ZEND_TYPE_NULLABLE_BIT; + } + flags + } + /// Calculates the internal flags of the type. /// Translation of the `ZEND_TYPE_INIT_CODE` macro from `zend_API.h:163`. /// @@ -159,7 +823,7 @@ impl ZendType { is_variadic: bool, allow_null: bool, ) -> u32 { - let type_ = type_.as_u32(); + let type_ = crate::flags::data_type_as_u32(&type_); (if type_ == _IS_BOOL { MAY_BE_BOOL @@ -174,3 +838,610 @@ impl ZendType { }) | Self::arg_info_flags(pass_by_ref, is_variadic) } } + +/// Maps a [`DataType`] to its single-bit `MAY_BE_*` mask, expanding the two +/// pseudo-codes (`_IS_BOOL`, `IS_MIXED`) the same way [`ZendType::type_init_code`] does. +fn primitive_may_be(dt: DataType) -> u32 { + let code = crate::flags::data_type_as_u32(&dt); + if code == _IS_BOOL { + MAY_BE_BOOL + } else if code == IS_MIXED { + MAY_BE_ANY + } else { + 1u32 << code + } +} + +/// Allocates and populates a `zend_type_list` for a sequence of class names. +/// +/// Used by both the `arg_info` path +/// ([`ZendType::empty_from_class_intersection`] / [`ZendType::empty_from_dnf`], +/// PHP 8.3+) and the property path ([`ZendType::empty_from_class_union_for_property`] +/// and friends, PHP 8.1+ depending on variant). DNF nests one of these lists +/// per intersection group. The caller owns the bit flags on the outer +/// `zend_type` that points at this list; this helper only handles the list +/// itself and its entries. +/// +/// `interned` selects the lifetime model: +/// +/// - `true` (`arg_info`): each `zend_string` is allocated via +/// [`crate::ffi::ext_php_rs_zend_string_init_persistent_interned`], which +/// sets `IS_STR_INTERNED` so `zend_string_release` becomes a no-op. The +/// caller MUST set `_ZEND_TYPE_ARENA_BIT` on the parent mask so Zend's +/// `zend_type_release` (`Zend/zend_opcode.c:112-124`) skips the `pefree` of +/// the list. Net effect: the list and strings live for the process +/// lifetime — needed because `#[php_module]` caches function entries +/// across embed-test re-init cycles, and the engine would otherwise free +/// the list-owned strings out from under our cached `arg_info`. +/// - `false` (property): each `zend_string` is allocated via +/// [`crate::ffi::ext_php_rs_zend_string_init`] with `persistent = true`, +/// producing a refcounted persistent string. The caller MUST NOT set +/// `_ZEND_TYPE_ARENA_BIT`; Zend's `zend_type_release` then `pefree`s the +/// list and `zend_string_release`s each entry at internal-class destroy +/// (MSHUTDOWN). Property registration runs through `ClassBuilder::register` +/// per MINIT against a fresh class entry, so the engine-managed cleanup +/// matches the per-cycle allocation lifetime — no accumulating leak in +/// embed tests, no double-free, mirrors what php-src `gen_stub.php` emits +/// for typed property declarations on every supported version. +/// +/// The engine processes our pre-built list directly: in +/// `zend_register_functions` 8.3+ via `ZEND_TYPE_HAS_LITERAL_NAME` (`arg_info`), +/// and in `zend_declare_typed_property` on every supported version +/// (property). PHP 8.1/8.2's `zend_register_functions` rejected pre-built +/// lists for `arg_info` — that's why the `cfg(php83)` gate stays on the +/// `arg_info` callers — but `zend_declare_typed_property` accepts them on 8.1+. +/// +/// Returns [`None`] when `class_names` is empty, any name has interior NUL +/// bytes or is empty, or allocation fails. Each entry is tagged +/// `_ZEND_TYPE_NAME_BIT` regardless of `interned`; only the underlying +/// `zend_string` allocator differs. +fn build_class_list( + class_names: &[String], + interned: bool, +) -> Option<*mut crate::ffi::zend_type_list> { + if class_names.is_empty() { + return None; + } + + for name in class_names { + if name.is_empty() || name.as_bytes().contains(&0u8) { + return None; + } + } + + let num_types = u32::try_from(class_names.len()).ok()?; + + // SAFETY: Layout matches Zend's `ZEND_TYPE_LIST_SIZE(num_types)` macro + // (`Zend/zend_types.h`). The `types` field is a flexible array + // member declared as `[zend_type; 1]`, so the struct already + // accounts for one entry; remaining entries are tail-allocated. + let list_size = std::mem::size_of::() + + (class_names.len().saturating_sub(1)) * std::mem::size_of::(); + + // SAFETY: Allocates with `pemalloc(_, 1)`. With `interned = true`, the + // caller sets the arena bit on the parent mask so Zend's + // `zend_type_release` skips the `pefree` of this list. With + // `interned = false`, Zend `pefree`s this allocation at class destroy. + let list_ptr = unsafe { crate::ffi::ext_php_rs_pemalloc_persistent(list_size) } + .cast::(); + + if list_ptr.is_null() { + return None; + } + + // SAFETY: `list_ptr` points to a freshly-allocated `zend_type_list` + // with capacity for `num_types` entries. + unsafe { + (*list_ptr).num_types = num_types; + } + + for (i, name) in class_names.iter().enumerate() { + let str_ptr = unsafe { + if interned { + crate::ffi::ext_php_rs_zend_string_init_persistent_interned( + name.as_ptr().cast::(), + name.len(), + ) + } else { + crate::ffi::ext_php_rs_zend_string_init( + name.as_ptr().cast::(), + name.len(), + true, + ) + } + }; + if str_ptr.is_null() { + // No teardown needed: Zend will reclaim the partially-built + // list and any strings already attached when the module + // fails to load (the outer caller propagates None as an + // `Error::InvalidCString`). + return None; + } + + // SAFETY: `types` is a flexible array; index `i` is within the + // freshly-allocated capacity (num_types entries). + unsafe { + let entry = (*list_ptr).types.as_mut_ptr().add(i); + *entry = zend_type { + ptr: str_ptr.cast::(), + type_mask: crate::ffi::_ZEND_TYPE_NAME_BIT, + }; + } + } + + Some(list_ptr) +} + +/// Returns `true` when the given DNF term is a legal shape: +/// `Single` carries a non-empty NUL-free name; `Intersection` carries 2 or +/// more such names. One-element intersection groups are rejected to keep a +/// single canonical Rust spelling per legal PHP type. +#[cfg(php82)] +fn dnf_term_is_valid(term: &DnfTerm) -> bool { + match term { + DnfTerm::Single(name) => !name.is_empty() && !name.as_bytes().contains(&0u8), + DnfTerm::Intersection(names) => { + names.len() >= 2 + && names + .iter() + .all(|n| !n.is_empty() && !n.as_bytes().contains(&0u8)) + } + } +} + +#[cfg(all(test, php83))] +mod intersection_tests { + use super::*; + use crate::ffi::{ + _ZEND_TYPE_ARENA_BIT, _ZEND_TYPE_INTERSECTION_BIT, _ZEND_TYPE_LIST_BIT, + _ZEND_TYPE_NAME_BIT, _ZEND_TYPE_NULLABLE_BIT, zend_type_list, + }; + + #[test] + fn empty_from_class_intersection_sets_list_intersection_and_arena_bits() { + let names = vec!["Countable".to_owned(), "Traversable".to_owned()]; + let ty = ZendType::empty_from_class_intersection(&names, false, false, false) + .expect("intersection should build"); + + assert_ne!(ty.type_mask & _ZEND_TYPE_LIST_BIT, 0); + assert_ne!(ty.type_mask & _ZEND_TYPE_INTERSECTION_BIT, 0); + assert_ne!( + ty.type_mask & _ZEND_TYPE_ARENA_BIT, + 0, + "arena bit must be set so Zend keeps its hands off the list" + ); + assert_eq!(ty.type_mask & _ZEND_TYPE_NULLABLE_BIT, 0); + assert!(!ty.ptr.is_null()); + + let list = ty.ptr.cast::(); + let num = unsafe { (*list).num_types }; + assert_eq!(num, 2); + } + + #[test] + fn empty_from_class_intersection_rejects_nullable() { + let names = vec!["Countable".to_owned(), "Traversable".to_owned()]; + let ty = ZendType::empty_from_class_intersection(&names, false, false, true); + assert!( + ty.is_none(), + "nullable intersection should be rejected (DNF in slice 04)" + ); + } + + #[test] + fn empty_from_class_intersection_rejects_empty() { + let names: Vec = vec![]; + let ty = ZendType::empty_from_class_intersection(&names, false, false, false); + assert!(ty.is_none(), "empty intersection should be rejected"); + } + + #[test] + fn empty_from_class_intersection_rejects_interior_nul() { + let names = vec!["Foo".to_owned(), "B\0ar".to_owned()]; + let ty = ZendType::empty_from_class_intersection(&names, false, false, false); + assert!(ty.is_none(), "names with NUL bytes should be rejected"); + } + + #[test] + fn empty_from_class_intersection_marks_each_entry_as_name_bit() { + let names = vec!["Foo".to_owned(), "Bar".to_owned()]; + let ty = ZendType::empty_from_class_intersection(&names, false, false, false) + .expect("intersection should build"); + + let list = ty.ptr.cast::(); + let entries = unsafe { (*list).types.as_ptr() }; + for i in 0..2 { + let entry = unsafe { *entries.add(i) }; + assert_ne!( + entry.type_mask & _ZEND_TYPE_NAME_BIT, + 0, + "entry {i} must carry _ZEND_TYPE_NAME_BIT" + ); + assert!(!entry.ptr.is_null(), "entry {i} must hold a zend_string*"); + } + } +} + +#[cfg(all(test, php83))] +mod dnf_tests { + use super::*; + use crate::ffi::{ + _ZEND_TYPE_ARENA_BIT, _ZEND_TYPE_INTERSECTION_BIT, _ZEND_TYPE_LIST_BIT, + _ZEND_TYPE_NAME_BIT, _ZEND_TYPE_NULLABLE_BIT, _ZEND_TYPE_UNION_BIT, zend_type_list, + }; + + fn dnf_a_and_b_or_c() -> Vec { + vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ] + } + + #[test] + fn empty_from_dnf_sets_outer_list_union_arena_bits() { + let terms = dnf_a_and_b_or_c(); + let ty = ZendType::empty_from_dnf(&terms, false, false, false).expect("DNF should build"); + + assert_ne!(ty.type_mask & _ZEND_TYPE_LIST_BIT, 0); + assert_ne!(ty.type_mask & _ZEND_TYPE_UNION_BIT, 0); + assert_ne!( + ty.type_mask & _ZEND_TYPE_ARENA_BIT, + 0, + "arena bit must be set on the outer DNF list", + ); + assert_eq!( + ty.type_mask & _ZEND_TYPE_INTERSECTION_BIT, + 0, + "outer DNF list is a union, not an intersection", + ); + assert_eq!(ty.type_mask & _ZEND_TYPE_NULLABLE_BIT, 0); + assert!(!ty.ptr.is_null()); + + let list = ty.ptr.cast::(); + let num = unsafe { (*list).num_types }; + assert_eq!(num, 2); + } + + #[test] + fn empty_from_dnf_intersection_term_has_list_intersection_arena_bits() { + let terms = dnf_a_and_b_or_c(); + let ty = ZendType::empty_from_dnf(&terms, false, false, false).expect("DNF should build"); + + let list = ty.ptr.cast::(); + let entry0 = unsafe { *(*list).types.as_ptr() }; + + assert_ne!(entry0.type_mask & _ZEND_TYPE_LIST_BIT, 0); + assert_ne!(entry0.type_mask & _ZEND_TYPE_INTERSECTION_BIT, 0); + assert_ne!( + entry0.type_mask & _ZEND_TYPE_ARENA_BIT, + 0, + "inner intersection list must also carry the arena bit", + ); + assert!(!entry0.ptr.is_null()); + } + + #[test] + fn empty_from_dnf_single_class_term_has_name_bit_only() { + let terms = dnf_a_and_b_or_c(); + let ty = ZendType::empty_from_dnf(&terms, false, false, false).expect("DNF should build"); + + let list = ty.ptr.cast::(); + let entry1 = unsafe { *(*list).types.as_ptr().add(1) }; + + assert_ne!(entry1.type_mask & _ZEND_TYPE_NAME_BIT, 0); + assert_eq!( + entry1.type_mask & _ZEND_TYPE_LIST_BIT, + 0, + "single-class term is not a list", + ); + assert!(!entry1.ptr.is_null(), "must hold a zend_string*"); + } + + #[test] + fn empty_from_dnf_with_allow_null_sets_nullable_bit() { + let terms = dnf_a_and_b_or_c(); + let ty = ZendType::empty_from_dnf(&terms, false, false, true) + .expect("nullable DNF should build"); + + assert_ne!( + ty.type_mask & _ZEND_TYPE_NULLABLE_BIT, + 0, + "allow_null must propagate _ZEND_TYPE_NULLABLE_BIT", + ); + } + + #[test] + fn empty_from_dnf_two_intersection_terms() { + let terms = vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Intersection(vec!["C".to_owned(), "D".to_owned()]), + ]; + let ty = ZendType::empty_from_dnf(&terms, false, false, false) + .expect("(A&B)|(C&D) should build"); + + let list = ty.ptr.cast::(); + for i in 0..2 { + let entry = unsafe { *(*list).types.as_ptr().add(i) }; + assert_ne!(entry.type_mask & _ZEND_TYPE_LIST_BIT, 0); + assert_ne!(entry.type_mask & _ZEND_TYPE_INTERSECTION_BIT, 0); + assert_ne!(entry.type_mask & _ZEND_TYPE_ARENA_BIT, 0); + } + } + + #[test] + fn empty_from_dnf_rejects_empty_terms() { + assert!(ZendType::empty_from_dnf(&[], false, false, false).is_none()); + } + + #[test] + fn empty_from_dnf_rejects_single_class_only() { + let terms = vec![DnfTerm::Single("C".to_owned())]; + assert!( + ZendType::empty_from_dnf(&terms, false, false, false).is_none(), + "single-class DNF should be rejected (use PhpType::Simple)", + ); + } + + #[test] + fn empty_from_dnf_rejects_single_intersection_only() { + let terms = vec![DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()])]; + assert!( + ZendType::empty_from_dnf(&terms, false, false, false).is_none(), + "single-intersection DNF should be rejected (use PhpType::Intersection)", + ); + } + + #[test] + fn empty_from_dnf_rejects_intersection_with_one_member() { + let terms = vec![ + DnfTerm::Intersection(vec!["A".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]; + assert!( + ZendType::empty_from_dnf(&terms, false, false, false).is_none(), + "single-element intersection group should be rejected", + ); + } + + #[test] + fn empty_from_dnf_rejects_interior_nul() { + let terms = vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B\0".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]; + assert!(ZendType::empty_from_dnf(&terms, false, false, false).is_none()); + + let terms = vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C\0".to_owned()), + ]; + assert!(ZendType::empty_from_dnf(&terms, false, false, false).is_none()); + } + + #[test] + fn empty_from_dnf_rejects_empty_class_name() { + let terms = vec![ + DnfTerm::Intersection(vec!["A".to_owned(), String::new()]), + DnfTerm::Single("C".to_owned()), + ]; + assert!(ZendType::empty_from_dnf(&terms, false, false, false).is_none()); + + let terms = vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single(String::new()), + ]; + assert!(ZendType::empty_from_dnf(&terms, false, false, false).is_none()); + } +} + +#[cfg(test)] +mod property_tests { + use super::*; + use crate::ffi::{_ZEND_TYPE_LIST_BIT, _ZEND_TYPE_NULLABLE_BIT, IS_LONG, IS_STRING}; + use crate::types::PhpType; + + #[cfg(feature = "embed")] + use crate::ffi::{ + _ZEND_TYPE_ARENA_BIT, _ZEND_TYPE_NAME_BIT, _ZEND_TYPE_UNION_BIT, zend_type_list, + }; + + fn may_be_long() -> u32 { + 1u32 << IS_LONG + } + + fn may_be_string() -> u32 { + 1u32 << IS_STRING + } + + #[test] + fn empty_for_property_simple_primitive_emits_type_mask_only() { + let ty = ZendType::empty_for_property(&PhpType::Simple(DataType::Long), false) + .expect("simple primitive should build"); + + assert!(ty.ptr.is_null(), "primitive must not carry a pointer"); + assert_ne!(ty.type_mask & may_be_long(), 0); + assert_eq!( + ty.type_mask & _ZEND_TYPE_LIST_BIT, + 0, + "primitive must not set the list bit", + ); + assert_eq!(ty.type_mask & _ZEND_TYPE_NULLABLE_BIT, 0); + } + + #[test] + fn empty_for_property_nullable_primitive_sets_nullable_bit() { + let ty = ZendType::empty_for_property(&PhpType::Simple(DataType::Long), true) + .expect("nullable primitive should build"); + + assert_ne!(ty.type_mask & _ZEND_TYPE_NULLABLE_BIT, 0); + assert_ne!(ty.type_mask & may_be_long(), 0); + } + + #[test] + fn empty_for_property_primitive_union_ors_may_be_bits() { + let ty = ZendType::empty_for_property( + &PhpType::Union(vec![DataType::Long, DataType::String]), + false, + ) + .expect("primitive union should build"); + + assert!(ty.ptr.is_null(), "primitive union must not carry a pointer"); + assert_ne!(ty.type_mask & may_be_long(), 0); + assert_ne!(ty.type_mask & may_be_string(), 0); + assert_eq!(ty.type_mask & _ZEND_TYPE_LIST_BIT, 0); + } + + #[test] + #[cfg(feature = "embed")] + fn empty_for_property_class_emits_name_bit_with_zend_string() { + crate::embed::Embed::run(|| { + let ty = ZendType::empty_for_property( + &PhpType::Simple(DataType::Object(Some("Foo"))), + false, + ) + .expect("class should build"); + + assert_ne!( + ty.type_mask & _ZEND_TYPE_NAME_BIT, + 0, + "single class property must carry _ZEND_TYPE_NAME_BIT", + ); + assert_eq!( + ty.type_mask & _ZEND_TYPE_LIST_BIT, + 0, + "single class property must not set the list bit", + ); + assert_eq!(ty.type_mask & _ZEND_TYPE_NULLABLE_BIT, 0); + assert!( + !ty.ptr.is_null(), + "single class property must hold a zend_string pointer", + ); + }); + } + + #[test] + #[cfg(feature = "embed")] + fn empty_for_property_class_union_builds_list_without_arena() { + crate::embed::Embed::run(|| { + let ty = ZendType::empty_for_property( + &PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]), + false, + ) + .expect("class union property should build"); + + assert_ne!(ty.type_mask & _ZEND_TYPE_LIST_BIT, 0); + assert_ne!(ty.type_mask & _ZEND_TYPE_UNION_BIT, 0); + assert_eq!( + ty.type_mask & _ZEND_TYPE_ARENA_BIT, + 0, + "property class union must NOT set the arena bit (engine-managed cleanup)", + ); + + let list = ty.ptr.cast::(); + let num = unsafe { (*list).num_types }; + assert_eq!(num, 2); + + for i in 0..2 { + let entry = unsafe { *(*list).types.as_ptr().add(i) }; + assert_ne!(entry.type_mask & _ZEND_TYPE_NAME_BIT, 0); + assert!(!entry.ptr.is_null()); + } + }); + } + + #[test] + #[cfg(all(feature = "embed", php81))] + fn empty_for_property_class_intersection_no_arena() { + crate::embed::Embed::run(|| { + let ty = ZendType::empty_for_property( + &PhpType::Intersection(vec!["Countable".to_owned(), "Traversable".to_owned()]), + false, + ) + .expect("intersection property should build on 8.1+"); + + assert_ne!(ty.type_mask & _ZEND_TYPE_LIST_BIT, 0); + assert_ne!(ty.type_mask & crate::ffi::_ZEND_TYPE_INTERSECTION_BIT, 0,); + assert_eq!( + ty.type_mask & _ZEND_TYPE_ARENA_BIT, + 0, + "property intersection must NOT set the arena bit", + ); + }); + } + + #[test] + #[cfg(all(feature = "embed", php82))] + fn empty_for_property_dnf_no_arena_at_any_level() { + crate::embed::Embed::run(|| { + let terms = vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]; + let ty = ZendType::empty_for_property(&PhpType::Dnf(terms), false) + .expect("DNF property should build on 8.2+"); + + assert_ne!(ty.type_mask & _ZEND_TYPE_LIST_BIT, 0); + assert_ne!(ty.type_mask & _ZEND_TYPE_UNION_BIT, 0); + assert_eq!( + ty.type_mask & _ZEND_TYPE_ARENA_BIT, + 0, + "outer DNF list must NOT set arena bit", + ); + + let list = ty.ptr.cast::(); + let entry0 = unsafe { *(*list).types.as_ptr() }; + assert_ne!(entry0.type_mask & _ZEND_TYPE_LIST_BIT, 0); + assert_ne!( + entry0.type_mask & crate::ffi::_ZEND_TYPE_INTERSECTION_BIT, + 0, + ); + assert_eq!( + entry0.type_mask & _ZEND_TYPE_ARENA_BIT, + 0, + "inner intersection list must NOT set arena bit", + ); + + let entry1 = unsafe { *(*list).types.as_ptr().add(1) }; + assert_ne!(entry1.type_mask & _ZEND_TYPE_NAME_BIT, 0); + assert_eq!( + entry1.type_mask & _ZEND_TYPE_LIST_BIT, + 0, + "single-class DNF term is not a list", + ); + }); + } + + #[test] + fn empty_for_property_rejects_empty_class_union() { + let ty = ZendType::empty_for_property(&PhpType::ClassUnion(vec![]), false); + assert!(ty.is_none(), "empty class union must be rejected"); + } + + #[test] + fn empty_for_property_rejects_empty_class_name() { + let ty = ZendType::empty_for_property(&PhpType::Simple(DataType::Object(Some(""))), false); + assert!(ty.is_none(), "empty class name must be rejected"); + } + + #[cfg(not(php81))] + #[test] + fn empty_for_property_intersection_returns_none_pre_81() { + let ty = ZendType::empty_for_property( + &PhpType::Intersection(vec!["A".to_owned(), "B".to_owned()]), + false, + ); + assert!(ty.is_none(), "intersection property is 8.1+"); + } + + #[cfg(not(php83))] + #[test] + fn empty_for_property_dnf_returns_none_pre_83() { + use crate::types::DnfTerm; + let terms = vec![ + DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]), + DnfTerm::Single("C".to_owned()), + ]; + let ty = ZendType::empty_for_property(&PhpType::Dnf(terms), false); + assert!(ty.is_none(), "DNF property registration is 8.3+"); + } +} diff --git a/src/zend/expected_type.rs b/src/zend/expected_type.rs new file mode 100644 index 000000000..36d3733e0 --- /dev/null +++ b/src/zend/expected_type.rs @@ -0,0 +1,380 @@ +//! Safe wrapper around PHP's legacy `_zend_expected_type` discriminant. +//! +//! [`ExpectedType`] is a typed Rust mirror of the small set of `Z_EXPECTED_*` +//! values that ext-php-rs supports for the legacy ZPP error path. Callers +//! receive an [`ExpectedType`] from [`crate::args::Arg::expected_type`] and +//! pass it to [`wrong_parameter_type_error`] without ever touching the raw +//! FFI integer. + +use crate::ffi; +use crate::flags::DataType; +use crate::types::Zval; + +/// Subset of PHP's `Z_EXPECTED_*` discriminants that map cleanly to a single +/// scalar [`crate::flags::DataType`]. +/// +/// Compound PHP types (unions, intersections, DNF) have no equivalent in +/// PHP's fixed enum and are reported through the modern +/// `zend_argument_type_error` path instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ExpectedType { + /// `int` — `Z_EXPECTED_LONG`. + Long, + /// `?int` — `Z_EXPECTED_LONG_OR_NULL`. + LongOrNull, + /// `bool` — `Z_EXPECTED_BOOL`. + Bool, + /// `?bool` — `Z_EXPECTED_BOOL_OR_NULL`. + BoolOrNull, + /// `string` — `Z_EXPECTED_STRING`. + String, + /// `?string` — `Z_EXPECTED_STRING_OR_NULL`. + StringOrNull, + /// `array` — `Z_EXPECTED_ARRAY`. + Array, + /// `?array` — `Z_EXPECTED_ARRAY_OR_NULL`. + ArrayOrNull, + /// `object` — `Z_EXPECTED_OBJECT`. + Object, + /// `?object` — `Z_EXPECTED_OBJECT_OR_NULL`. + ObjectOrNull, + /// `float` — `Z_EXPECTED_DOUBLE`. + Double, + /// `?float` — `Z_EXPECTED_DOUBLE_OR_NULL`. + DoubleOrNull, + /// `resource` — `Z_EXPECTED_RESOURCE`. + Resource, + /// `?resource` — `Z_EXPECTED_RESOURCE_OR_NULL`. + ResourceOrNull, +} + +impl ExpectedType { + /// Map a scalar [`DataType`] plus a nullability flag to the matching + /// discriminant. Returns `None` for `DataType` variants that have no + /// `Z_EXPECTED_*` slot (e.g. `Mixed`, `Void`, `Iterable`, `Callable`, + /// `Null`). + pub(crate) fn from_simple(dt: DataType, nullable: bool) -> Option { + Some(match (dt, nullable) { + (DataType::Long, false) => Self::Long, + (DataType::Long, true) => Self::LongOrNull, + (DataType::Bool | DataType::True | DataType::False, false) => Self::Bool, + (DataType::Bool | DataType::True | DataType::False, true) => Self::BoolOrNull, + (DataType::String, false) => Self::String, + (DataType::String, true) => Self::StringOrNull, + (DataType::Array, false) => Self::Array, + (DataType::Array, true) => Self::ArrayOrNull, + (DataType::Object(_), false) => Self::Object, + (DataType::Object(_), true) => Self::ObjectOrNull, + (DataType::Double, false) => Self::Double, + (DataType::Double, true) => Self::DoubleOrNull, + (DataType::Resource, false) => Self::Resource, + (DataType::Resource, true) => Self::ResourceOrNull, + _ => return None, + }) + } + + pub(crate) fn into_raw(self) -> ffi::_zend_expected_type { + match self { + Self::Long => ffi::_zend_expected_type_Z_EXPECTED_LONG, + Self::LongOrNull => ffi::_zend_expected_type_Z_EXPECTED_LONG_OR_NULL, + Self::Bool => ffi::_zend_expected_type_Z_EXPECTED_BOOL, + Self::BoolOrNull => ffi::_zend_expected_type_Z_EXPECTED_BOOL_OR_NULL, + Self::String => ffi::_zend_expected_type_Z_EXPECTED_STRING, + Self::StringOrNull => ffi::_zend_expected_type_Z_EXPECTED_STRING_OR_NULL, + Self::Array => ffi::_zend_expected_type_Z_EXPECTED_ARRAY, + Self::ArrayOrNull => ffi::_zend_expected_type_Z_EXPECTED_ARRAY_OR_NULL, + Self::Object => ffi::_zend_expected_type_Z_EXPECTED_OBJECT, + Self::ObjectOrNull => ffi::_zend_expected_type_Z_EXPECTED_OBJECT_OR_NULL, + Self::Double => ffi::_zend_expected_type_Z_EXPECTED_DOUBLE, + Self::DoubleOrNull => ffi::_zend_expected_type_Z_EXPECTED_DOUBLE_OR_NULL, + Self::Resource => ffi::_zend_expected_type_Z_EXPECTED_RESOURCE, + Self::ResourceOrNull => ffi::_zend_expected_type_Z_EXPECTED_RESOURCE_OR_NULL, + } + } +} + +/// Reports a wrong-type argument through PHP's legacy ZPP error helper +/// (`zend_wrong_parameter_type_error`). +/// +/// Use this when you have a scalar [`ExpectedType`] from +/// [`crate::args::Arg::expected_type`]. For compound declared types use +/// [`crate::exception::PhpException`] or call `zend_argument_type_error` +/// with a custom message built from [`crate::args::Arg::ty`]. +/// +/// # Parameters +/// +/// * `arg_num` - 1-based argument index, as PHP expects. +/// * `expected` - The expected type discriminant. +/// * `given` - The actual value PHP received. +pub fn wrong_parameter_type_error(arg_num: u32, expected: ExpectedType, given: &Zval) { + // SAFETY: `given` is a live `&Zval`. PHP's C signature is `const zval *`, + // but bindgen drops `const` on pointer parameters; the cast to `*mut` + // is sound because the engine only reads the value. + unsafe { + ffi::zend_wrong_parameter_type_error( + arg_num, + expected.into_raw(), + std::ptr::from_ref::(given).cast_mut(), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ffi; + use crate::flags::DataType; + + #[test] + fn wrong_parameter_type_error_signature_is_stable() { + // Catches FFI-binding drift if PHP renames or re-shapes + // `zend_wrong_parameter_type_error`. Behavioural verification (the + // engine actually queues a TypeError) requires an active execute + // frame and lives in the integration test suite, not here, because + // PHP's helper calls `get_active_function_or_method_name()` which + // asserts `zend_is_executing()`. + let _: fn(u32, ExpectedType, &Zval) = wrong_parameter_type_error; + } + + #[test] + fn from_simple_long() { + assert_eq!( + ExpectedType::from_simple(DataType::Long, false), + Some(ExpectedType::Long), + ); + } + + #[test] + fn from_simple_long_nullable() { + assert_eq!( + ExpectedType::from_simple(DataType::Long, true), + Some(ExpectedType::LongOrNull), + ); + } + + #[test] + fn from_simple_bool_via_false_alias() { + assert_eq!( + ExpectedType::from_simple(DataType::False, false), + Some(ExpectedType::Bool), + ); + } + + #[test] + fn from_simple_bool_via_true_alias() { + assert_eq!( + ExpectedType::from_simple(DataType::True, false), + Some(ExpectedType::Bool), + ); + } + + #[test] + fn from_simple_bool_nullable() { + assert_eq!( + ExpectedType::from_simple(DataType::Bool, true), + Some(ExpectedType::BoolOrNull), + ); + } + + #[test] + fn from_simple_string() { + assert_eq!( + ExpectedType::from_simple(DataType::String, false), + Some(ExpectedType::String), + ); + } + + #[test] + fn from_simple_string_nullable() { + assert_eq!( + ExpectedType::from_simple(DataType::String, true), + Some(ExpectedType::StringOrNull), + ); + } + + #[test] + fn from_simple_array() { + assert_eq!( + ExpectedType::from_simple(DataType::Array, false), + Some(ExpectedType::Array), + ); + } + + #[test] + fn from_simple_array_nullable() { + assert_eq!( + ExpectedType::from_simple(DataType::Array, true), + Some(ExpectedType::ArrayOrNull), + ); + } + + #[test] + fn from_simple_object_with_class_name() { + assert_eq!( + ExpectedType::from_simple(DataType::Object(Some("Foo")), false), + Some(ExpectedType::Object), + ); + } + + #[test] + fn from_simple_object_without_class_name_nullable() { + assert_eq!( + ExpectedType::from_simple(DataType::Object(None), true), + Some(ExpectedType::ObjectOrNull), + ); + } + + #[test] + fn from_simple_double() { + assert_eq!( + ExpectedType::from_simple(DataType::Double, false), + Some(ExpectedType::Double), + ); + } + + #[test] + fn from_simple_double_nullable() { + assert_eq!( + ExpectedType::from_simple(DataType::Double, true), + Some(ExpectedType::DoubleOrNull), + ); + } + + #[test] + fn from_simple_resource() { + assert_eq!( + ExpectedType::from_simple(DataType::Resource, false), + Some(ExpectedType::Resource), + ); + } + + #[test] + fn from_simple_resource_nullable() { + assert_eq!( + ExpectedType::from_simple(DataType::Resource, true), + Some(ExpectedType::ResourceOrNull), + ); + } + + #[test] + fn from_simple_unsupported_returns_none() { + assert!(ExpectedType::from_simple(DataType::Mixed, false).is_none()); + assert!(ExpectedType::from_simple(DataType::Void, false).is_none()); + assert!(ExpectedType::from_simple(DataType::Iterable, false).is_none()); + assert!(ExpectedType::from_simple(DataType::Callable, false).is_none()); + assert!(ExpectedType::from_simple(DataType::Null, false).is_none()); + } + + #[test] + fn into_raw_long() { + assert_eq!( + ExpectedType::Long.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_LONG + ); + } + + #[test] + fn into_raw_long_or_null() { + assert_eq!( + ExpectedType::LongOrNull.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_LONG_OR_NULL + ); + } + + #[test] + fn into_raw_bool() { + assert_eq!( + ExpectedType::Bool.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_BOOL + ); + } + + #[test] + fn into_raw_bool_or_null() { + assert_eq!( + ExpectedType::BoolOrNull.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_BOOL_OR_NULL + ); + } + + #[test] + fn into_raw_string() { + assert_eq!( + ExpectedType::String.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_STRING + ); + } + + #[test] + fn into_raw_string_or_null() { + assert_eq!( + ExpectedType::StringOrNull.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_STRING_OR_NULL + ); + } + + #[test] + fn into_raw_array() { + assert_eq!( + ExpectedType::Array.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_ARRAY + ); + } + + #[test] + fn into_raw_array_or_null() { + assert_eq!( + ExpectedType::ArrayOrNull.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_ARRAY_OR_NULL + ); + } + + #[test] + fn into_raw_object() { + assert_eq!( + ExpectedType::Object.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_OBJECT + ); + } + + #[test] + fn into_raw_object_or_null() { + assert_eq!( + ExpectedType::ObjectOrNull.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_OBJECT_OR_NULL + ); + } + + #[test] + fn into_raw_double() { + assert_eq!( + ExpectedType::Double.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_DOUBLE + ); + } + + #[test] + fn into_raw_double_or_null() { + assert_eq!( + ExpectedType::DoubleOrNull.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_DOUBLE_OR_NULL + ); + } + + #[test] + fn into_raw_resource() { + assert_eq!( + ExpectedType::Resource.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_RESOURCE + ); + } + + #[test] + fn into_raw_resource_or_null() { + assert_eq!( + ExpectedType::ResourceOrNull.into_raw(), + ffi::_zend_expected_type_Z_EXPECTED_RESOURCE_OR_NULL + ); + } +} diff --git a/src/zend/mod.rs b/src/zend/mod.rs index 1235edc43..2bbc09b2e 100644 --- a/src/zend/mod.rs +++ b/src/zend/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod error_observer; mod ex; #[cfg(feature = "observer")] pub(crate) mod exception_observer; +mod expected_type; mod function; mod globals; mod handlers; @@ -39,6 +40,7 @@ pub use error_observer::{BacktraceFrame, ErrorInfo, ErrorObserver, ErrorType}; pub use ex::ExecuteData; #[cfg(feature = "observer")] pub use exception_observer::{ExceptionInfo, ExceptionObserver}; +pub use expected_type::{ExpectedType, wrong_parameter_type_error}; pub use function::Function; pub use function::FunctionEntry; pub use globals::ExecutorGlobals; diff --git a/src/zend/module.rs b/src/zend/module.rs index 8eff7b339..9fa47b7b0 100644 --- a/src/zend/module.rs +++ b/src/zend/module.rs @@ -122,8 +122,15 @@ pub unsafe fn cleanup_module_allocations(entry: *mut ModuleEntry) { if !arg.default_value.is_null() { unsafe { drop(CString::from_raw(arg.default_value.cast_mut())) }; } - if !arg.type_.ptr.is_null() && zend_type_has_name(arg.type_.type_mask) { - unsafe { drop(CString::from_raw(arg.type_.ptr.cast::())) }; + if !arg.type_.ptr.is_null() { + if (arg.type_.type_mask & crate::ffi::_ZEND_TYPE_LIST_BIT) != 0 { + // Zend frees the `zend_type_list` itself at MSHUTDOWN + // through `zend_type_release` -> `pefree(_, 1)`. See + // `Zend/zend_opcode.c:112-124` in php-src. Touching it + // here would double-free. + } else if zend_type_has_name(arg.type_.type_mask) { + unsafe { drop(CString::from_raw(arg.type_.ptr.cast::())) }; + } } } diff --git a/tests/src/integration/class_union/class_union.php b/tests/src/integration/class_union/class_union.php new file mode 100644 index 000000000..c0fd08768 --- /dev/null +++ b/tests/src/integration/class_union/class_union.php @@ -0,0 +1,82 @@ + 'test_class_union_arg', 'nullable' => false], + ['fname' => 'test_class_union_nullable_arg', 'nullable' => true], + ] as $case +) { + $rf = new ReflectionFunction($case['fname']); + $params = $rf->getParameters(); + assert(count($params) === 1, "{$case['fname']}: expected one parameter"); + + $type = $params[0]->getType(); + assert( + $type instanceof ReflectionUnionType, + "{$case['fname']}: expected ReflectionUnionType", + ); + assert( + $params[0]->allowsNull() === $case['nullable'], + "{$case['fname']}: nullable mismatch", + ); + + $members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $type->getTypes(), + ); + sort($members); + + $expected = ['ClassUnionLeft', 'ClassUnionRight']; + if ($case['nullable']) { + $expected[] = 'null'; + sort($expected); + } + assert( + $members === $expected, + "{$case['fname']}: expected " . implode('|', $expected) + . ', got ' . implode('|', $members), + ); +} + +foreach ( + [ + ['fname' => 'test_class_union_returns', 'nullable' => false], + ['fname' => 'test_class_union_nullable_returns', 'nullable' => true], + ] as $case +) { + $rf = new ReflectionFunction($case['fname']); + $ret = $rf->getReturnType(); + assert( + $ret instanceof ReflectionUnionType, + "{$case['fname']}: expected ReflectionUnionType return", + ); + assert( + $ret->allowsNull() === $case['nullable'], + "{$case['fname']}: nullable mismatch on return", + ); + + $members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $ret->getTypes(), + ); + sort($members); + + $expected = ['ClassUnionLeft', 'ClassUnionRight']; + if ($case['nullable']) { + $expected[] = 'null'; + sort($expected); + } + assert( + $members === $expected, + "{$case['fname']}: expected " . implode('|', $expected) + . ', got ' . implode('|', $members), + ); +} diff --git a/tests/src/integration/class_union/mod.rs b/tests/src/integration/class_union/mod.rs new file mode 100644 index 000000000..c38e0598a --- /dev/null +++ b/tests/src/integration/class_union/mod.rs @@ -0,0 +1,88 @@ +use ext_php_rs::args::Arg; +use ext_php_rs::builders::FunctionBuilder; +use ext_php_rs::flags::DataType; +use ext_php_rs::prelude::*; +use ext_php_rs::types::{PhpType, Zval}; +use ext_php_rs::zend::ExecuteData; +use ext_php_rs::zend_fastcall; + +#[php_class] +pub struct ClassUnionLeft; + +#[php_class] +pub struct ClassUnionRight; + +fn class_union() -> PhpType { + PhpType::ClassUnion(vec![ + "ClassUnionLeft".to_owned(), + "ClassUnionRight".to_owned(), + ]) +} + +zend_fastcall! { + extern "C" fn handler_arg(execute_data: &mut ExecuteData, retval: &mut Zval) { + let mut arg = Arg::new("value", class_union()); + if execute_data.parser().arg(&mut arg).parse().is_err() { + return; + } + retval.set_long(1); + } +} + +zend_fastcall! { + extern "C" fn handler_nullable_arg(execute_data: &mut ExecuteData, retval: &mut Zval) { + let mut arg = Arg::new("value", class_union()).allow_null(); + if execute_data.parser().arg(&mut arg).parse().is_err() { + return; + } + retval.set_long(1); + } +} + +zend_fastcall! { + extern "C" fn handler_returns(execute_data: &mut ExecuteData, retval: &mut Zval) { + if execute_data.parser().parse().is_err() { + return; + } + retval.set_null(); + } +} + +pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { + let arg_fn = FunctionBuilder::new("test_class_union_arg", handler_arg) + .arg(Arg::new("value", class_union())) + .returns(DataType::Long, false, false); + + let nullable_arg_fn = + FunctionBuilder::new("test_class_union_nullable_arg", handler_nullable_arg) + .arg(Arg::new("value", class_union()).allow_null()) + .returns(DataType::Long, false, false); + + let returns_fn = FunctionBuilder::new("test_class_union_returns", handler_returns).returns( + class_union(), + false, + false, + ); + + let nullable_returns_fn = FunctionBuilder::new( + "test_class_union_nullable_returns", + handler_returns, + ) + .returns(class_union(), false, true); + + builder + .function(arg_fn) + .function(nullable_arg_fn) + .function(returns_fn) + .function(nullable_returns_fn) +} + +#[cfg(test)] +mod tests { + #[test] + fn class_union_metadata_matches_reflection() { + assert!(crate::integration::test::run_php( + "class_union/class_union.php" + )); + } +} diff --git a/tests/src/integration/dnf/dnf.php b/tests/src/integration/dnf/dnf.php new file mode 100644 index 000000000..14dc515f6 --- /dev/null +++ b/tests/src/integration/dnf/dnf.php @@ -0,0 +1,141 @@ +}|string> + */ +function dnf_member_shapes(ReflectionType $type): array { + if (!$type instanceof ReflectionUnionType) { + return []; + } + $out = []; + foreach ($type->getTypes() as $member) { + if ($member instanceof ReflectionIntersectionType) { + $names = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $member->getTypes(), + ); + sort($names); + $out[] = ['intersection' => $names]; + } elseif ($member instanceof ReflectionNamedType) { + $name = $member->getName(); + // PHP normalises a `null` member of a union to a separate + // ReflectionNamedType("null"); we keep it as a string so the + // assertion can spot it. + $out[] = $name; + } + } + usort($out, static function ($a, $b): int { + $ka = is_array($a) ? 'i:' . implode('&', $a['intersection']) : 's:' . $a; + $kb = is_array($b) ? 'i:' . implode('&', $b['intersection']) : 's:' . $b; + return strcmp($ka, $kb); + }); + return $out; +} + +// (DnfA&DnfB)|DnfC arg. +$rf = new ReflectionFunction('test_dnf_arg'); +$params = $rf->getParameters(); +assert(count($params) === 1, 'test_dnf_arg: expected one parameter'); + +$type = $params[0]->getType(); +assert( + $type instanceof ReflectionUnionType, + 'test_dnf_arg: expected ReflectionUnionType', +); +assert( + $params[0]->allowsNull() === false, + 'test_dnf_arg: must not allow null', +); + +$shapes = dnf_member_shapes($type); +assert( + $shapes === [['intersection' => ['DnfA', 'DnfB']], 'DnfC'], + 'test_dnf_arg: expected (DnfA&DnfB)|DnfC, got ' . json_encode($shapes), +); + +// (DnfA&DnfB)|DnfC|null arg via allow_null flag. +$rf = new ReflectionFunction('test_dnf_nullable_arg'); +$type = $rf->getParameters()[0]->getType(); +assert( + $type instanceof ReflectionUnionType, + 'test_dnf_nullable_arg: expected ReflectionUnionType', +); +assert( + $rf->getParameters()[0]->allowsNull() === true, + 'test_dnf_nullable_arg: must allow null', +); + +// (DnfA&DnfB)|(DnfA&DnfD) arg. +$rf = new ReflectionFunction('test_dnf_two_intersections_arg'); +$type = $rf->getParameters()[0]->getType(); +assert( + $type instanceof ReflectionUnionType, + 'test_dnf_two_intersections_arg: expected ReflectionUnionType', +); +$shapes = dnf_member_shapes($type); +assert( + $shapes === [ + ['intersection' => ['DnfA', 'DnfB']], + ['intersection' => ['DnfA', 'DnfD']], + ], + 'test_dnf_two_intersections_arg: expected (DnfA&DnfB)|(DnfA&DnfD), got ' + . json_encode($shapes), +); + +// (DnfA&DnfB)|DnfC return. +$rf = new ReflectionFunction('test_dnf_returns'); +$ret = $rf->getReturnType(); +assert( + $ret instanceof ReflectionUnionType, + 'test_dnf_returns: expected ReflectionUnionType return', +); +assert( + $ret->allowsNull() === false, + 'test_dnf_returns: must not allow null', +); +$shapes = dnf_member_shapes($ret); +assert( + $shapes === [['intersection' => ['DnfA', 'DnfB']], 'DnfC'], + 'test_dnf_returns: expected (DnfA&DnfB)|DnfC, got ' . json_encode($shapes), +); + +// (DnfA&DnfB)|DnfC|null return via allow_null flag. +$rf = new ReflectionFunction('test_dnf_nullable_returns'); +$ret = $rf->getReturnType(); +assert( + $ret instanceof ReflectionUnionType, + 'test_dnf_nullable_returns: expected ReflectionUnionType return', +); +assert( + $ret->allowsNull() === true, + 'test_dnf_nullable_returns: must allow null', +); + +// Smoke test that a value satisfying the DNF can flow through the call. +// Internal-function arg type enforcement only triggers in ZEND_DEBUG +// builds, so the call returns whether or not the argument shape matches; +// the metadata assertions above are the load-bearing checks. +$obj = new DnfC(); +assert(test_dnf_arg($obj) === 1, 'test_dnf_arg call must succeed'); +assert(test_dnf_nullable_arg(null) === 1, 'nullable DNF arg accepts null'); diff --git a/tests/src/integration/dnf/mod.rs b/tests/src/integration/dnf/mod.rs new file mode 100644 index 000000000..6af424986 --- /dev/null +++ b/tests/src/integration/dnf/mod.rs @@ -0,0 +1,101 @@ +use ext_php_rs::args::Arg; +use ext_php_rs::builders::FunctionBuilder; +use ext_php_rs::flags::DataType; +use ext_php_rs::prelude::*; +use ext_php_rs::types::{DnfTerm, PhpType, Zval}; +use ext_php_rs::zend::ExecuteData; +use ext_php_rs::zend_fastcall; + +fn dnf_a_and_b_or_c() -> PhpType { + PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["DnfA".to_owned(), "DnfB".to_owned()]), + DnfTerm::Single("DnfC".to_owned()), + ]) +} + +fn dnf_two_intersections() -> PhpType { + PhpType::Dnf(vec![ + DnfTerm::Intersection(vec!["DnfA".to_owned(), "DnfB".to_owned()]), + DnfTerm::Intersection(vec!["DnfA".to_owned(), "DnfD".to_owned()]), + ]) +} + +zend_fastcall! { + extern "C" fn handler_arg(execute_data: &mut ExecuteData, retval: &mut Zval) { + let mut arg = Arg::new("value", dnf_a_and_b_or_c()); + if execute_data.parser().arg(&mut arg).parse().is_err() { + return; + } + retval.set_long(1); + } +} + +zend_fastcall! { + extern "C" fn handler_nullable_arg(execute_data: &mut ExecuteData, retval: &mut Zval) { + let mut arg = Arg::new("value", dnf_a_and_b_or_c()).allow_null(); + if execute_data.parser().arg(&mut arg).parse().is_err() { + return; + } + retval.set_long(1); + } +} + +zend_fastcall! { + extern "C" fn handler_two_intersections_arg(execute_data: &mut ExecuteData, retval: &mut Zval) { + let mut arg = Arg::new("value", dnf_two_intersections()); + if execute_data.parser().arg(&mut arg).parse().is_err() { + return; + } + retval.set_long(1); + } +} + +zend_fastcall! { + extern "C" fn handler_returns(execute_data: &mut ExecuteData, retval: &mut Zval) { + if execute_data.parser().parse().is_err() { + return; + } + retval.set_null(); + } +} + +pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { + let arg_fn = FunctionBuilder::new("test_dnf_arg", handler_arg) + .arg(Arg::new("value", dnf_a_and_b_or_c())) + .returns(DataType::Long, false, false); + + let nullable_arg_fn = FunctionBuilder::new("test_dnf_nullable_arg", handler_nullable_arg) + .arg(Arg::new("value", dnf_a_and_b_or_c()).allow_null()) + .returns(DataType::Long, false, false); + + let two_intersections_arg_fn = FunctionBuilder::new( + "test_dnf_two_intersections_arg", + handler_two_intersections_arg, + ) + .arg(Arg::new("value", dnf_two_intersections())) + .returns(DataType::Long, false, false); + + let returns_fn = FunctionBuilder::new("test_dnf_returns", handler_returns).returns( + dnf_a_and_b_or_c(), + false, + false, + ); + + let nullable_returns_fn = FunctionBuilder::new("test_dnf_nullable_returns", handler_returns) + .returns(dnf_a_and_b_or_c(), false, true); + + builder + .function(arg_fn) + .function(nullable_arg_fn) + .function(two_intersections_arg_fn) + .function(returns_fn) + .function(nullable_returns_fn) +} + +#[cfg(test)] +mod tests { + #[test] + fn dnf_metadata_matches_reflection() { + assert!(crate::integration::test::run_php("dnf/dnf.php")); + } +} diff --git a/tests/src/integration/intersection/intersection.php b/tests/src/integration/intersection/intersection.php new file mode 100644 index 000000000..ebbac2020 --- /dev/null +++ b/tests/src/integration/intersection/intersection.php @@ -0,0 +1,58 @@ +getParameters(); +assert(count($params) === 1, 'test_intersection_arg: expected one parameter'); + +$type = $params[0]->getType(); +assert( + $type instanceof ReflectionIntersectionType, + 'test_intersection_arg: expected ReflectionIntersectionType', +); +assert( + $params[0]->allowsNull() === false, + 'test_intersection_arg: nullable intersections are deferred to slice 04', +); + +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $type->getTypes(), +); +sort($members); +$expected = ['Countable', 'Traversable']; +assert( + $members === $expected, + 'test_intersection_arg: expected ' . implode('&', $expected) + . ', got ' . implode('&', $members), +); + +$rf = new ReflectionFunction('test_intersection_returns'); +$ret = $rf->getReturnType(); +assert( + $ret instanceof ReflectionIntersectionType, + 'test_intersection_returns: expected ReflectionIntersectionType return', +); +assert( + $ret->allowsNull() === false, + 'test_intersection_returns: nullable intersections are deferred to slice 04', +); + +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $ret->getTypes(), +); +sort($members); +assert( + $members === $expected, + 'test_intersection_returns: expected ' . implode('&', $expected) + . ', got ' . implode('&', $members), +); diff --git a/tests/src/integration/intersection/mod.rs b/tests/src/integration/intersection/mod.rs new file mode 100644 index 000000000..a9558ea27 --- /dev/null +++ b/tests/src/integration/intersection/mod.rs @@ -0,0 +1,54 @@ +use ext_php_rs::args::Arg; +use ext_php_rs::builders::FunctionBuilder; +use ext_php_rs::flags::DataType; +use ext_php_rs::prelude::*; +use ext_php_rs::types::{PhpType, Zval}; +use ext_php_rs::zend::ExecuteData; +use ext_php_rs::zend_fastcall; + +fn intersection() -> PhpType { + PhpType::Intersection(vec!["Countable".to_owned(), "Traversable".to_owned()]) +} + +zend_fastcall! { + extern "C" fn handler_arg(execute_data: &mut ExecuteData, retval: &mut Zval) { + let mut arg = Arg::new("value", intersection()); + if execute_data.parser().arg(&mut arg).parse().is_err() { + return; + } + retval.set_long(1); + } +} + +zend_fastcall! { + extern "C" fn handler_returns(execute_data: &mut ExecuteData, retval: &mut Zval) { + if execute_data.parser().parse().is_err() { + return; + } + retval.set_null(); + } +} + +pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { + let arg_fn = FunctionBuilder::new("test_intersection_arg", handler_arg) + .arg(Arg::new("value", intersection())) + .returns(DataType::Long, false, false); + + let returns_fn = FunctionBuilder::new("test_intersection_returns", handler_returns).returns( + intersection(), + false, + false, + ); + + builder.function(arg_fn).function(returns_fn) +} + +#[cfg(test)] +mod tests { + #[test] + fn intersection_metadata_matches_reflection() { + assert!(crate::integration::test::run_php( + "intersection/intersection.php" + )); + } +} diff --git a/tests/src/integration/mod.rs b/tests/src/integration/mod.rs index cf96e02fc..4f4dc813e 100644 --- a/tests/src/integration/mod.rs +++ b/tests/src/integration/mod.rs @@ -4,13 +4,18 @@ pub mod binary; pub mod bool; pub mod callable; pub mod class; +pub mod class_union; pub mod closure; pub mod defaults; +#[cfg(php83)] +pub mod dnf; #[cfg(feature = "enum")] pub mod enum_; pub mod exception; pub mod globals; pub mod interface; +#[cfg(php83)] +pub mod intersection; pub mod iterator; pub mod magic_method; pub mod module_globals; @@ -20,10 +25,14 @@ pub mod object; #[cfg(feature = "observer")] pub mod observer; pub mod persistent_string; +pub mod php_types_attr; +pub mod php_union; pub mod reference; pub mod separated; pub mod string; +pub mod typed_property; pub mod types; +pub mod union; pub mod variadic_args; #[cfg(test)] @@ -40,6 +49,8 @@ mod test { BUILD.call_once(|| { let mut command = Command::new("cargo"); command.arg("build"); + // Don't let the parent cargo test leak -lphp into the cdylib. + command.env_remove("EXT_PHP_RS_LINK_LIBPHP"); #[cfg(not(debug_assertions))] command.arg("--release"); diff --git a/tests/src/integration/php_types_attr/mod.rs b/tests/src/integration/php_types_attr/mod.rs new file mode 100644 index 000000000..19e88ad40 --- /dev/null +++ b/tests/src/integration/php_types_attr/mod.rs @@ -0,0 +1,137 @@ +use ext_php_rs::prelude::*; +use ext_php_rs::types::Zval; + +#[php_class] +pub struct PhpTypesAttrFoo; + +#[php_class] +pub struct PhpTypesAttrBar; + +#[php_class] +pub struct PhpTypesAttrHolder; + +#[php_impl] +impl PhpTypesAttrHolder { + pub fn __construct() -> Self { + Self + } + + pub fn accept(#[php(types = "int|string")] _value: &Zval) -> i64 { + 1 + } + + #[php(returns = "int|string|null")] + pub fn produce() -> i64 { + 0 + } + + #[php(returns = "\\PhpTypesAttrFoo|\\PhpTypesAttrBar")] + pub fn produce_class_union() -> i64 { + 0 + } +} + +#[cfg(php83)] +#[php_class] +pub struct PhpTypesAttrHolder83; + +#[cfg(php83)] +#[php_impl] +impl PhpTypesAttrHolder83 { + pub fn __construct() -> Self { + Self + } + + #[php(returns = "\\Countable&\\Traversable")] + pub fn produce_intersection() -> i64 { + 0 + } + + #[php(returns = "(\\Countable&\\Traversable)|\\PhpTypesAttrFoo")] + pub fn produce_dnf() -> i64 { + 0 + } +} + +#[php_function] +pub fn test_attr_int_or_string(#[php(types = "int|string")] _value: &Zval) -> i64 { + 1 +} + +#[php_function] +#[php(returns = "int|string|null")] +pub fn test_attr_returns_int_string_or_null() -> i64 { + 0 +} + +#[php_function] +pub fn test_attr_class_union( + #[php(types = "\\PhpTypesAttrFoo|\\PhpTypesAttrBar")] _value: &Zval, +) -> i64 { + 1 +} + +#[cfg(php83)] +#[php_function] +pub fn test_attr_intersection(#[php(types = "\\Countable&\\Traversable")] _value: &Zval) -> i64 { + 1 +} + +#[cfg(php83)] +#[php_function] +pub fn test_attr_dnf( + #[php(types = "(\\Countable&\\Traversable)|\\PhpTypesAttrFoo")] _value: &Zval, +) -> i64 { + 1 +} + +#[php_function] +#[php(returns = "\\PhpTypesAttrFoo|\\PhpTypesAttrBar")] +pub fn test_attr_returns_class_union() -> i64 { + 0 +} + +#[cfg(php83)] +#[php_function] +#[php(returns = "\\Countable&\\Traversable")] +pub fn test_attr_returns_intersection() -> i64 { + 0 +} + +#[cfg(php83)] +#[php_function] +#[php(returns = "(\\Countable&\\Traversable)|\\PhpTypesAttrFoo")] +pub fn test_attr_returns_dnf() -> i64 { + 0 +} + +pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { + let builder = builder + .class::() + .class::() + .class::() + .function(wrap_function!(test_attr_int_or_string)) + .function(wrap_function!(test_attr_returns_int_string_or_null)) + .function(wrap_function!(test_attr_class_union)) + .function(wrap_function!(test_attr_returns_class_union)); + + #[cfg(php83)] + let builder = builder + .class::() + .function(wrap_function!(test_attr_intersection)) + .function(wrap_function!(test_attr_dnf)) + .function(wrap_function!(test_attr_returns_intersection)) + .function(wrap_function!(test_attr_returns_dnf)); + + builder +} + +#[cfg(test)] +mod tests { + #[test] + fn attr_int_or_string_metadata_matches_reflection() { + assert!(crate::integration::test::run_php( + "php_types_attr/php_types_attr.php" + )); + } +} diff --git a/tests/src/integration/php_types_attr/php_types_attr.php b/tests/src/integration/php_types_attr/php_types_attr.php new file mode 100644 index 000000000..21e3dcb53 --- /dev/null +++ b/tests/src/integration/php_types_attr/php_types_attr.php @@ -0,0 +1,366 @@ +getParameters(); +assert(count($params) === 1, 'expected one parameter'); + +$type = $params[0]->getType(); +assert( + $type instanceof ReflectionUnionType, + 'expected ReflectionUnionType, got ' . ($type ? $type::class : 'null'), +); +assert( + $params[0]->allowsNull() === false, + 'must not be nullable', +); + +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $type->getTypes(), +); +sort($members); +assert( + $members === ['int', 'string'], + 'expected int|string, got ' . implode('|', $members), +); + +$rf = new ReflectionFunction('test_attr_returns_int_string_or_null'); +$ret = $rf->getReturnType(); +assert( + $ret instanceof ReflectionUnionType, + 'expected ReflectionUnionType return, got ' . ($ret ? $ret::class : 'null'), +); +assert( + $ret->allowsNull() === true, + 'returns_int_string_or_null must allow null', +); + +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $ret->getTypes(), +); +sort($members); +assert( + $members === ['int', 'null', 'string'], + 'expected int|string|null on return, got ' . implode('|', $members), +); + +$rf = new ReflectionFunction('test_attr_class_union'); +$params = $rf->getParameters(); +assert(count($params) === 1, 'class union: expected one parameter'); + +$type = $params[0]->getType(); +assert( + $type instanceof ReflectionUnionType, + 'class union: expected ReflectionUnionType, got ' . ($type ? $type::class : 'null'), +); +assert( + $params[0]->allowsNull() === false, + 'class union must not be nullable', +); + +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $type->getTypes(), +); +sort($members); +assert( + $members === ['PhpTypesAttrBar', 'PhpTypesAttrFoo'], + 'expected PhpTypesAttrFoo|PhpTypesAttrBar, got ' . implode('|', $members), +); + +$rf = new ReflectionFunction('test_attr_returns_class_union'); +$ret = $rf->getReturnType(); +assert( + $ret instanceof ReflectionUnionType, + 'class union return: expected ReflectionUnionType, got ' . ($ret ? $ret::class : 'null'), +); +assert( + $ret->allowsNull() === false, + 'class union return must not be nullable', +); + +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $ret->getTypes(), +); +sort($members); +assert( + $members === ['PhpTypesAttrBar', 'PhpTypesAttrFoo'], + 'class union return: expected PhpTypesAttrFoo|PhpTypesAttrBar, got ' . implode('|', $members), +); + +if (PHP_VERSION_ID >= 80300) { + $rf = new ReflectionFunction('test_attr_intersection'); + $params = $rf->getParameters(); + assert(count($params) === 1, 'intersection: expected one parameter'); + + $type = $params[0]->getType(); + assert( + $type instanceof ReflectionIntersectionType, + 'intersection: expected ReflectionIntersectionType, got ' + . ($type ? $type::class : 'null'), + ); + + $members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $type->getTypes(), + ); + sort($members); + assert( + $members === ['Countable', 'Traversable'], + 'expected Countable&Traversable, got ' . implode('&', $members), + ); + + $rf = new ReflectionFunction('test_attr_dnf'); + $params = $rf->getParameters(); + assert(count($params) === 1, 'dnf: expected one parameter'); + + $type = $params[0]->getType(); + assert( + $type instanceof ReflectionUnionType, + 'dnf: expected ReflectionUnionType (DNF), got ' . ($type ? $type::class : 'null'), + ); + + $branches = $type->getTypes(); + assert(count($branches) === 2, 'dnf: expected two top-level branches'); + + $named = []; + $intersection = null; + foreach ($branches as $branch) { + if ($branch instanceof ReflectionIntersectionType) { + assert($intersection === null, 'dnf: more than one intersection branch'); + $intersection = $branch; + continue; + } + assert( + $branch instanceof ReflectionNamedType, + 'dnf: unexpected branch class ' . $branch::class, + ); + $named[] = $branch->getName(); + } + sort($named); + assert( + $named === ['PhpTypesAttrFoo'], + 'dnf: expected named branch PhpTypesAttrFoo, got ' . implode(',', $named), + ); + + assert($intersection !== null, 'dnf: missing intersection branch'); + $intersection_members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $intersection->getTypes(), + ); + sort($intersection_members); + assert( + $intersection_members === ['Countable', 'Traversable'], + 'dnf: expected Countable&Traversable inner intersection, got ' + . implode('&', $intersection_members), + ); + + $rf = new ReflectionFunction('test_attr_returns_intersection'); + $ret = $rf->getReturnType(); + assert( + $ret instanceof ReflectionIntersectionType, + 'intersection return: expected ReflectionIntersectionType, got ' + . ($ret ? $ret::class : 'null'), + ); + + $members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $ret->getTypes(), + ); + sort($members); + assert( + $members === ['Countable', 'Traversable'], + 'intersection return: expected Countable&Traversable, got ' . implode('&', $members), + ); + + $rf = new ReflectionFunction('test_attr_returns_dnf'); + $ret = $rf->getReturnType(); + assert( + $ret instanceof ReflectionUnionType, + 'dnf return: expected ReflectionUnionType (DNF), got ' . ($ret ? $ret::class : 'null'), + ); + + $branches = $ret->getTypes(); + assert(count($branches) === 2, 'dnf return: expected two top-level branches'); + + $named = []; + $intersection = null; + foreach ($branches as $branch) { + if ($branch instanceof ReflectionIntersectionType) { + assert($intersection === null, 'dnf return: more than one intersection branch'); + $intersection = $branch; + continue; + } + assert( + $branch instanceof ReflectionNamedType, + 'dnf return: unexpected branch class ' . $branch::class, + ); + $named[] = $branch->getName(); + } + sort($named); + assert( + $named === ['PhpTypesAttrFoo'], + 'dnf return: expected named branch PhpTypesAttrFoo, got ' . implode(',', $named), + ); + + assert($intersection !== null, 'dnf return: missing intersection branch'); + $intersection_members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $intersection->getTypes(), + ); + sort($intersection_members); + assert( + $intersection_members === ['Countable', 'Traversable'], + 'dnf return: expected Countable&Traversable inner intersection, got ' + . implode('&', $intersection_members), + ); +} + +// `#[php_impl]` method coverage: per-arg `types` and method-level `returns`. +$rm = new ReflectionMethod('PhpTypesAttrHolder', 'accept'); +$params = $rm->getParameters(); +assert(count($params) === 1, 'PhpTypesAttrHolder::accept: expected one parameter'); + +$type = $params[0]->getType(); +assert( + $type instanceof ReflectionUnionType, + 'PhpTypesAttrHolder::accept: expected ReflectionUnionType, got ' + . ($type ? $type::class : 'null'), +); + +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $type->getTypes(), +); +sort($members); +assert( + $members === ['int', 'string'], + 'PhpTypesAttrHolder::accept: expected int|string, got ' . implode('|', $members), +); + +$rm = new ReflectionMethod('PhpTypesAttrHolder', 'produce'); +$ret = $rm->getReturnType(); +assert( + $ret instanceof ReflectionUnionType, + 'PhpTypesAttrHolder::produce: expected ReflectionUnionType, got ' + . ($ret ? $ret::class : 'null'), +); +assert( + $ret->allowsNull() === true, + 'PhpTypesAttrHolder::produce: must allow null on return', +); + +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $ret->getTypes(), +); +sort($members); +assert( + $members === ['int', 'null', 'string'], + 'PhpTypesAttrHolder::produce: expected int|string|null, got ' . implode('|', $members), +); + +$rm = new ReflectionMethod('PhpTypesAttrHolder', 'produceClassUnion'); +$ret = $rm->getReturnType(); +assert( + $ret instanceof ReflectionUnionType, + 'PhpTypesAttrHolder::produceClassUnion: expected ReflectionUnionType, got ' + . ($ret ? $ret::class : 'null'), +); +assert( + $ret->allowsNull() === false, + 'PhpTypesAttrHolder::produceClassUnion: must not allow null on return', +); + +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $ret->getTypes(), +); +sort($members); +assert( + $members === ['PhpTypesAttrBar', 'PhpTypesAttrFoo'], + 'PhpTypesAttrHolder::produceClassUnion: expected PhpTypesAttrFoo|PhpTypesAttrBar, got ' + . implode('|', $members), +); + +if (PHP_VERSION_ID >= 80300) { + $rm = new ReflectionMethod('PhpTypesAttrHolder83', 'produceIntersection'); + $ret = $rm->getReturnType(); + assert( + $ret instanceof ReflectionIntersectionType, + 'PhpTypesAttrHolder83::produceIntersection: expected ReflectionIntersectionType, got ' + . ($ret ? $ret::class : 'null'), + ); + + $members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $ret->getTypes(), + ); + sort($members); + assert( + $members === ['Countable', 'Traversable'], + 'PhpTypesAttrHolder83::produceIntersection: expected Countable&Traversable, got ' + . implode('&', $members), + ); + + $rm = new ReflectionMethod('PhpTypesAttrHolder83', 'produceDnf'); + $ret = $rm->getReturnType(); + assert( + $ret instanceof ReflectionUnionType, + 'PhpTypesAttrHolder83::produceDnf: expected ReflectionUnionType (DNF), got ' + . ($ret ? $ret::class : 'null'), + ); + + $branches = $ret->getTypes(); + assert( + count($branches) === 2, + 'PhpTypesAttrHolder83::produceDnf: expected two top-level branches', + ); + + $named = []; + $intersection = null; + foreach ($branches as $branch) { + if ($branch instanceof ReflectionIntersectionType) { + assert( + $intersection === null, + 'PhpTypesAttrHolder83::produceDnf: more than one intersection branch', + ); + $intersection = $branch; + continue; + } + assert( + $branch instanceof ReflectionNamedType, + 'PhpTypesAttrHolder83::produceDnf: unexpected branch class ' . $branch::class, + ); + $named[] = $branch->getName(); + } + sort($named); + assert( + $named === ['PhpTypesAttrFoo'], + 'PhpTypesAttrHolder83::produceDnf: expected named branch PhpTypesAttrFoo, got ' + . implode(',', $named), + ); + + assert( + $intersection !== null, + 'PhpTypesAttrHolder83::produceDnf: missing intersection branch', + ); + $intersection_members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $intersection->getTypes(), + ); + sort($intersection_members); + assert( + $intersection_members === ['Countable', 'Traversable'], + 'PhpTypesAttrHolder83::produceDnf: expected Countable&Traversable inner intersection, got ' + . implode('&', $intersection_members), + ); +} diff --git a/tests/src/integration/php_union/mod.rs b/tests/src/integration/php_union/mod.rs new file mode 100644 index 000000000..e3b6a05e0 --- /dev/null +++ b/tests/src/integration/php_union/mod.rs @@ -0,0 +1,118 @@ +use ext_php_rs::prelude::*; + +#[derive(PhpUnion)] +pub enum IntOrString { + Int(i64), + Str(String), +} + +#[php_function] +pub fn test_php_union_param(value: IntOrString) -> i64 { + match value { + IntOrString::Int(_) => 1, + IntOrString::Str(_) => 2, + } +} + +#[php_function] +pub fn test_php_union_return(flag: bool) -> IntOrString { + if flag { + IntOrString::Int(7) + } else { + IntOrString::Str("hi".to_owned()) + } +} + +#[php_class] +pub struct PhpUnionHolder; + +#[php_impl] +impl PhpUnionHolder { + pub fn __construct() -> Self { + Self + } + + pub fn accept(value: IntOrString) -> IntOrString { + value + } +} + +pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { + builder + .class::() + .function(wrap_function!(test_php_union_param)) + .function(wrap_function!(test_php_union_return)) +} + +#[cfg(test)] +mod tests { + use super::IntOrString; + use ext_php_rs::convert::{FromZval, FromZvalMut, IntoZval}; + use ext_php_rs::flags::DataType; + use ext_php_rs::types::{PhpType, PhpUnion}; + + #[test] + fn union_types_emits_long_then_string() { + assert_eq!( + ::union_types(), + PhpType::Union(vec![DataType::Long, DataType::String]), + ); + } + + #[test] + fn into_zval_php_type_delegates_to_union_types() { + assert_eq!( + ::php_type(), + PhpType::Union(vec![DataType::Long, DataType::String]), + ); + } + + #[test] + fn from_zval_php_type_delegates_to_union_types() { + assert_eq!( + ::php_type(), + PhpType::Union(vec![DataType::Long, DataType::String]), + ); + } + + #[test] + fn from_zval_mut_php_type_forwards_through_blanket() { + assert_eq!( + ::php_type(), + PhpType::Union(vec![DataType::Long, DataType::String]), + ); + } + + #[test] + fn default_php_type_wraps_simple_for_primitive() { + assert_eq!( + ::php_type(), + PhpType::Simple(DataType::Long) + ); + assert_eq!( + ::php_type(), + PhpType::Simple(DataType::Long) + ); + assert_eq!( + ::php_type(), + PhpType::Simple(DataType::Long) + ); + } + + #[test] + fn option_forwards_php_type_to_inner() { + assert_eq!( + as IntoZval>::php_type(), + PhpType::Simple(DataType::Long), + ); + assert_eq!( + as IntoZval>::php_type(), + PhpType::Union(vec![DataType::Long, DataType::String]), + ); + } + + #[test] + fn php_union_reflection_and_call_round_trip() { + assert!(crate::integration::test::run_php("php_union/php_union.php")); + } +} diff --git a/tests/src/integration/php_union/php_union.php b/tests/src/integration/php_union/php_union.php new file mode 100644 index 000000000..036627ca3 --- /dev/null +++ b/tests/src/integration/php_union/php_union.php @@ -0,0 +1,142 @@ +getParameters(); +assert(count($params) === 1, 'param: expected one parameter'); + +$type = $params[0]->getType(); +assert( + $type instanceof ReflectionUnionType, + 'param: expected ReflectionUnionType, got ' . ($type ? $type::class : 'null'), +); +assert( + $params[0]->allowsNull() === false, + 'param: must not be nullable', +); + +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $type->getTypes(), +); +sort($members); +assert( + $members === ['int', 'string'], + 'param: expected int|string, got ' . implode('|', $members), +); + +$ret = $rf->getReturnType(); +assert( + $ret instanceof ReflectionNamedType, + 'param: expected ReflectionNamedType return (i64), got ' + . ($ret ? $ret::class : 'null'), +); +assert( + $ret->getName() === 'int', + 'param: expected int return, got ' . $ret->getName(), +); + +$rf = new ReflectionFunction('test_php_union_return'); +$params = $rf->getParameters(); +assert(count($params) === 1, 'return: expected one parameter'); +assert( + $params[0]->getType() instanceof ReflectionNamedType, + 'return: expected ReflectionNamedType (bool) param', +); +assert( + $params[0]->getType()->getName() === 'bool', + 'return: expected bool param, got ' . $params[0]->getType()->getName(), +); + +$ret = $rf->getReturnType(); +assert( + $ret instanceof ReflectionUnionType, + 'return: expected ReflectionUnionType, got ' . ($ret ? $ret::class : 'null'), +); +assert( + $ret->allowsNull() === false, + 'return: must not be nullable', +); + +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $ret->getTypes(), +); +sort($members); +assert( + $members === ['int', 'string'], + 'return: expected int|string return, got ' . implode('|', $members), +); + +// End-to-end: param dispatch picks the right variant in the FromZval impl. +assert( + test_php_union_param(42) === 1, + 'call: int input must dispatch to IntOrString::Int', +); +assert( + test_php_union_param('hi') === 2, + 'call: string input must dispatch to IntOrString::Str', +); + +// End-to-end: return dispatch picks the right variant in the IntoZval impl. +assert( + test_php_union_return(true) === 7, + 'call: true must return the i64 variant carrying 7', +); +assert( + test_php_union_return(false) === 'hi', + 'call: false must return the String variant carrying "hi"', +); + +// `#[php_impl]` method coverage: the same machinery wires through to methods. +$rm = new ReflectionMethod('PhpUnionHolder', 'accept'); +$params = $rm->getParameters(); +assert(count($params) === 1, 'method: expected one parameter'); + +$type = $params[0]->getType(); +assert( + $type instanceof ReflectionUnionType, + 'method: expected ReflectionUnionType param, got ' + . ($type ? $type::class : 'null'), +); +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $type->getTypes(), +); +sort($members); +assert( + $members === ['int', 'string'], + 'method: expected int|string param, got ' . implode('|', $members), +); + +$ret = $rm->getReturnType(); +assert( + $ret instanceof ReflectionUnionType, + 'method: expected ReflectionUnionType return, got ' + . ($ret ? $ret::class : 'null'), +); +$members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $ret->getTypes(), +); +sort($members); +assert( + $members === ['int', 'string'], + 'method: expected int|string return, got ' . implode('|', $members), +); + +assert( + PhpUnionHolder::accept(99) === 99, + 'method call: int must round-trip', +); +assert( + PhpUnionHolder::accept('hello') === 'hello', + 'method call: string must round-trip', +); diff --git a/tests/src/integration/typed_property/mod.rs b/tests/src/integration/typed_property/mod.rs new file mode 100644 index 000000000..bfde9e28e --- /dev/null +++ b/tests/src/integration/typed_property/mod.rs @@ -0,0 +1,148 @@ +use ext_php_rs::builders::{ClassBuilder, ClassProperty}; +use ext_php_rs::flags::{DataType, PropertyFlags}; +use ext_php_rs::prelude::*; +use ext_php_rs::types::PhpType; + +#[php_class] +#[php(modifier = inject_typed_props)] +pub struct TypedPropClass; + +#[php_impl] +impl TypedPropClass { + pub fn __construct() -> Self { + Self + } +} + +#[php_class] +pub struct TypedPropFooClass; + +#[php_impl] +impl TypedPropFooClass { + pub fn __construct() -> Self { + Self + } +} + +#[php_class] +pub struct TypedPropBarClass; + +#[php_impl] +impl TypedPropBarClass { + pub fn __construct() -> Self { + Self + } +} + +fn inject_typed_props(b: ClassBuilder) -> ClassBuilder { + let mut b = b + .property(ClassProperty { + name: "intProp".into(), + flags: PropertyFlags::Public, + default: None, + docs: &[], + ty: Some(PhpType::Simple(DataType::Long)), + nullable: false, + readonly: false, + default_stub: None, + }) + .property(ClassProperty { + name: "nullableIntProp".into(), + flags: PropertyFlags::Public, + default: None, + docs: &[], + ty: Some(PhpType::Simple(DataType::Long)), + nullable: true, + readonly: false, + default_stub: None, + }) + .property(ClassProperty { + name: "stringOrIntProp".into(), + flags: PropertyFlags::Public, + default: None, + docs: &[], + ty: Some(PhpType::Union(vec![DataType::String, DataType::Long])), + nullable: false, + readonly: false, + default_stub: None, + }) + .property(ClassProperty { + name: "fooProp".into(), + flags: PropertyFlags::Public, + default: None, + docs: &[], + ty: Some(PhpType::Simple(DataType::Object(Some("TypedPropFooClass")))), + nullable: false, + readonly: false, + default_stub: None, + }) + .property(ClassProperty { + name: "fooOrBarProp".into(), + flags: PropertyFlags::Public, + default: None, + docs: &[], + ty: Some(PhpType::ClassUnion(vec![ + "TypedPropFooClass".into(), + "TypedPropBarClass".into(), + ])), + nullable: false, + readonly: false, + default_stub: None, + }); + + #[cfg(php81)] + { + b = b.property(ClassProperty { + name: "intersectProp".into(), + flags: PropertyFlags::Public, + default: None, + docs: &[], + ty: Some(PhpType::Intersection(vec![ + "Countable".into(), + "Traversable".into(), + ])), + nullable: false, + readonly: false, + default_stub: None, + }); + } + + #[cfg(php83)] + { + b = b.property(ClassProperty { + name: "dnfProp".into(), + flags: PropertyFlags::Public, + default: None, + docs: &[], + ty: Some(PhpType::Dnf(vec![ + ext_php_rs::types::DnfTerm::Intersection(vec![ + "Countable".into(), + "Traversable".into(), + ]), + ext_php_rs::types::DnfTerm::Single("TypedPropFooClass".into()), + ])), + nullable: false, + readonly: false, + default_stub: None, + }); + } + + b +} + +pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { + builder + .class::() + .class::() + .class::() +} + +#[cfg(test)] +mod tests { + #[test] + fn typed_property_metadata_matches_reflection() { + assert!(crate::integration::test::run_php( + "typed_property/typed_property.php" + )); + } +} diff --git a/tests/src/integration/typed_property/typed_property.php b/tests/src/integration/typed_property/typed_property.php new file mode 100644 index 000000000..d45af6c35 --- /dev/null +++ b/tests/src/integration/typed_property/typed_property.php @@ -0,0 +1,237 @@ += 80100) { + $declaredNames[] = 'intersectProp'; +} +if (PHP_VERSION_ID >= 80300) { + $declaredNames[] = 'dnfProp'; +} +foreach ($declaredNames as $declaredName) { + $reflProp = $rc->getProperty($declaredName); + assert( + $reflProp->getName() === $declaredName, + "property name round-trip failed for '$declaredName', got '" + . $reflProp->getName() . "'", + ); +} + +// 1. Simple primitive (int) +$intProp = $rc->getProperty('intProp'); +$intType = $intProp->getType(); +assert($intType instanceof ReflectionNamedType, 'intProp: expected ReflectionNamedType'); +assert($intType->getName() === 'int', 'intProp: expected int, got ' . $intType->getName()); +assert(!$intType->allowsNull(), 'intProp: expected not nullable'); + +// 2. Nullable primitive (?int) +$nullableIntProp = $rc->getProperty('nullableIntProp'); +$nullableIntType = $nullableIntProp->getType(); +assert( + $nullableIntType instanceof ReflectionNamedType, + 'nullableIntProp: expected ReflectionNamedType', +); +assert( + $nullableIntType->getName() === 'int', + 'nullableIntProp: expected int, got ' . $nullableIntType->getName(), +); +assert($nullableIntType->allowsNull(), 'nullableIntProp: expected nullable'); + +// 3. Primitive union (int|string) +$unionProp = $rc->getProperty('stringOrIntProp'); +$unionType = $unionProp->getType(); +assert($unionType instanceof ReflectionUnionType, 'stringOrIntProp: expected ReflectionUnionType'); +$members = array_map(static fn(ReflectionNamedType $t): string => $t->getName(), $unionType->getTypes()); +sort($members); +assert( + $members === ['int', 'string'], + 'stringOrIntProp: expected int|string, got ' . implode('|', $members), +); + +// 4. Single class +$fooProp = $rc->getProperty('fooProp'); +$fooType = $fooProp->getType(); +assert($fooType instanceof ReflectionNamedType, 'fooProp: expected ReflectionNamedType'); +assert( + $fooType->getName() === 'TypedPropFooClass', + 'fooProp: expected TypedPropFooClass, got ' . $fooType->getName(), +); +assert(!$fooType->allowsNull(), 'fooProp: expected not nullable'); + +// 5. Class union (Foo|Bar) +$fooOrBarProp = $rc->getProperty('fooOrBarProp'); +$fooOrBarType = $fooOrBarProp->getType(); +assert( + $fooOrBarType instanceof ReflectionUnionType, + 'fooOrBarProp: expected ReflectionUnionType', +); +$classMembers = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $fooOrBarType->getTypes(), +); +sort($classMembers); +assert( + $classMembers === ['TypedPropBarClass', 'TypedPropFooClass'], + 'fooOrBarProp: expected TypedPropFooClass|TypedPropBarClass, got ' + . implode('|', $classMembers), +); + +// 6. Intersection (Countable&Traversable) on PHP 8.1+ +if (PHP_VERSION_ID >= 80100) { + $intersectProp = $rc->getProperty('intersectProp'); + $intersectType = $intersectProp->getType(); + assert( + $intersectType instanceof ReflectionIntersectionType, + 'intersectProp: expected ReflectionIntersectionType', + ); + $intersectMembers = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $intersectType->getTypes(), + ); + sort($intersectMembers); + assert( + $intersectMembers === ['Countable', 'Traversable'], + 'intersectProp: expected Countable&Traversable, got ' + . implode('&', $intersectMembers), + ); +} + +// 7. DNF ((Countable&Traversable)|TypedPropFooClass) on PHP 8.3+ +if (PHP_VERSION_ID >= 80300) { + $dnfProp = $rc->getProperty('dnfProp'); + $dnfType = $dnfProp->getType(); + assert( + $dnfType instanceof ReflectionUnionType, + 'dnfProp: expected ReflectionUnionType (DNF outer is union)', + ); + $dnfTypeStrings = array_map( + static fn($t): string => $t instanceof ReflectionIntersectionType + ? '(' . implode('&', array_map( + static fn(ReflectionNamedType $n): string => $n->getName(), + $t->getTypes(), + )) . ')' + : $t->getName(), + $dnfType->getTypes(), + ); + sort($dnfTypeStrings); + assert( + $dnfTypeStrings === ['(Countable&Traversable)', 'TypedPropFooClass'], + 'dnfProp: expected (Countable&Traversable)|TypedPropFooClass, got ' + . implode('|', $dnfTypeStrings), + ); +} + +// Runtime enforcement: TypeError on bad assignments +$obj = new TypedPropClass(); + +// intProp must reject string +$caught = false; +try { + $obj->intProp = 'not an int'; +} catch (TypeError) { + $caught = true; +} +assert($caught, 'intProp must reject string assignment with TypeError'); + +// nullableIntProp accepts null +$obj->nullableIntProp = null; +assert($obj->nullableIntProp === null, 'nullableIntProp must accept null'); +$obj->nullableIntProp = 42; +assert($obj->nullableIntProp === 42, 'nullableIntProp must accept int'); + +// stringOrIntProp accepts string and int but rejects array +$obj->stringOrIntProp = 'hello'; +assert($obj->stringOrIntProp === 'hello', 'stringOrIntProp must accept string'); +$obj->stringOrIntProp = 7; +assert($obj->stringOrIntProp === 7, 'stringOrIntProp must accept int'); +$caught = false; +try { + $obj->stringOrIntProp = []; +} catch (TypeError) { + $caught = true; +} +assert($caught, 'stringOrIntProp must reject array assignment'); + +// fooProp accepts TypedPropFooClass but rejects TypedPropBarClass +$obj->fooProp = new TypedPropFooClass(); +$caught = false; +try { + $obj->fooProp = new TypedPropBarClass(); +} catch (TypeError) { + $caught = true; +} +assert($caught, 'fooProp must reject TypedPropBarClass assignment'); + +// fooOrBarProp accepts both +$obj->fooOrBarProp = new TypedPropFooClass(); +$obj->fooOrBarProp = new TypedPropBarClass(); +$caught = false; +try { + $obj->fooOrBarProp = new stdClass(); +} catch (TypeError) { + $caught = true; +} +assert($caught, 'fooOrBarProp must reject stdClass assignment'); + +if (PHP_VERSION_ID >= 80100) { + // intersectProp accepts ArrayObject (Countable+Traversable) but rejects stdClass + $obj->intersectProp = new ArrayObject(); + $caught = false; + try { + $obj->intersectProp = new stdClass(); + } catch (TypeError) { + $caught = true; + } + assert($caught, 'intersectProp must reject stdClass assignment'); +} + +if (PHP_VERSION_ID >= 80300) { + // dnfProp accepts ArrayObject (matches first arm) and TypedPropFooClass (matches second) + $obj->dnfProp = new ArrayObject(); + $obj->dnfProp = new TypedPropFooClass(); + $caught = false; + try { + $obj->dnfProp = new TypedPropBarClass(); + } catch (TypeError) { + $caught = true; + } + assert($caught, 'dnfProp must reject TypedPropBarClass assignment'); +} + +// IS_UNDEF default: a typed property registered with no explicit default must +// be in `IS_PROP_UNINIT` state, so reading before the first assignment throws +// `Error`. Guards the `Zval::undef()` path in `register_property` — if we +// used `Zval::new()` (`IS_NULL`), declaration would either fail with +// TypeError on non-nullable properties or the read would silently return +// null on nullable ones. +$freshObj = new TypedPropClass(); +$thrown = null; +try { + $_ = $freshObj->intProp; +} catch (Error $e) { + $thrown = $e; +} +assert( + $thrown instanceof Error, + 'reading uninitialized typed property must throw Error ' + . '(IS_PROP_UNINIT semantics)', +); + +echo "typed_property: ok\n"; diff --git a/tests/src/integration/union/mod.rs b/tests/src/integration/union/mod.rs new file mode 100644 index 000000000..45d67821b --- /dev/null +++ b/tests/src/integration/union/mod.rs @@ -0,0 +1,142 @@ +use ext_php_rs::args::Arg; +use ext_php_rs::builders::FunctionBuilder; +use ext_php_rs::flags::DataType; +use ext_php_rs::prelude::*; +use ext_php_rs::types::{PhpType, Zval}; +use ext_php_rs::zend::ExecuteData; +use ext_php_rs::zend_fastcall; + +/// Maps the parsed [`Zval`] to a small integer code so PHP-side assertions can +/// distinguish which union member was received without inspecting the value +/// itself: 1 = int, 2 = string, 3 = null, 0 = other / parse failure. +fn classify(zval: Option<&Zval>, retval: &mut Zval) { + let code = match zval { + Some(z) if z.is_null() => 3, + Some(z) if z.is_long() => 1, + Some(z) if z.is_string() => 2, + _ => 0, + }; + retval.set_long(code); +} + +zend_fastcall! { + extern "C" fn handler_int_or_string(execute_data: &mut ExecuteData, retval: &mut Zval) { + let mut arg = Arg::new( + "value", + PhpType::Union(vec![DataType::Long, DataType::String]), + ); + if execute_data.parser().arg(&mut arg).parse().is_err() { + return; + } + classify(arg.zval().map(|z| &**z), retval); + } +} + +zend_fastcall! { + extern "C" fn handler_int_string_or_null(execute_data: &mut ExecuteData, retval: &mut Zval) { + let mut arg = Arg::new( + "value", + PhpType::Union(vec![DataType::Long, DataType::String, DataType::Null]), + ); + if execute_data.parser().arg(&mut arg).parse().is_err() { + return; + } + classify(arg.zval().map(|z| &**z), retval); + } +} + +zend_fastcall! { + extern "C" fn handler_int_string_allow_null(execute_data: &mut ExecuteData, retval: &mut Zval) { + let mut arg = Arg::new( + "value", + PhpType::Union(vec![DataType::Long, DataType::String]), + ); + if execute_data.parser().arg(&mut arg).parse().is_err() { + return; + } + classify(arg.zval().map(|z| &**z), retval); + } +} + +zend_fastcall! { + extern "C" fn handler_returns_int_or_string(execute_data: &mut ExecuteData, retval: &mut Zval) { + if execute_data.parser().parse().is_err() { + return; + } + retval.set_long(1); + } +} + +zend_fastcall! { + extern "C" fn handler_returns_int_string_or_null( + execute_data: &mut ExecuteData, + retval: &mut Zval, + ) { + if execute_data.parser().parse().is_err() { + return; + } + retval.set_null(); + } +} + +pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { + let int_or_string = FunctionBuilder::new("test_union_int_or_string", handler_int_or_string) + .arg(Arg::new( + "value", + PhpType::Union(vec![DataType::Long, DataType::String]), + )) + .returns(DataType::Long, false, false); + + let int_string_or_null = + FunctionBuilder::new("test_union_int_string_or_null", handler_int_string_or_null) + .arg(Arg::new( + "value", + PhpType::Union(vec![DataType::Long, DataType::String, DataType::Null]), + )) + .returns(DataType::Long, false, false); + + let int_string_allow_null = FunctionBuilder::new( + "test_union_int_string_allow_null", + handler_int_string_allow_null, + ) + .arg( + Arg::new( + "value", + PhpType::Union(vec![DataType::Long, DataType::String]), + ) + .allow_null(), + ) + .returns(DataType::Long, false, false); + + let returns_int_or_string = + FunctionBuilder::new("test_returns_int_or_string", handler_returns_int_or_string).returns( + PhpType::Union(vec![DataType::Long, DataType::String]), + false, + false, + ); + + let returns_int_string_or_null = FunctionBuilder::new( + "test_returns_int_string_or_null", + handler_returns_int_string_or_null, + ) + .returns( + PhpType::Union(vec![DataType::Long, DataType::String, DataType::Null]), + false, + false, + ); + + builder + .function(int_or_string) + .function(int_string_or_null) + .function(int_string_allow_null) + .function(returns_int_or_string) + .function(returns_int_string_or_null) +} + +#[cfg(test)] +mod tests { + #[test] + fn union_int_or_string_works() { + 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..2a6acceb7 --- /dev/null +++ b/tests/src/integration/union/union.php @@ -0,0 +1,84 @@ +getParameters(); +assert(count($params) === 1, 'expected exactly one parameter'); + +$type = $params[0]->getType(); +assert($type instanceof ReflectionUnionType, 'expected a union type'); +assert($params[0]->allowsNull() === false, 'union must not be nullable'); + +$members = array_map(static fn(ReflectionNamedType $t): string => $t->getName(), $type->getTypes()); +sort($members); +assert($members === ['int', 'string'], 'expected int|string members, got ' . implode('|', $members)); + +// End-to-end: function is callable with each accepted member. +assert(test_union_int_or_string(42) === 1); +assert(test_union_int_or_string("hello") === 2); + +// Slice 2: nullable union, in both spellings. +foreach ( + ['test_union_int_string_or_null', 'test_union_int_string_allow_null'] as $fname +) { + $rf = new ReflectionFunction($fname); + $param = $rf->getParameters()[0]; + $type = $param->getType(); + assert($type instanceof ReflectionUnionType, "$fname: expected union type"); + assert($param->allowsNull() === true, "$fname: expected nullable"); + + $members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $type->getTypes(), + ); + sort($members); + assert( + $members === ['int', 'null', 'string'], + "$fname: expected int|null|string, got " . implode('|', $members), + ); + + assert($fname(42) === 1, "$fname: int call"); + assert($fname("hello") === 2, "$fname: string call"); + assert($fname(null) === 3, "$fname: null call"); +} + +// Slice 3: union return types. +foreach ([ + [ + 'fname' => 'test_returns_int_or_string', + 'nullable' => false, + 'members' => ['int', 'string'], + ], + [ + 'fname' => 'test_returns_int_string_or_null', + 'nullable' => true, + 'members' => ['int', 'null', 'string'], + ], +] as $case) { + $rf = new ReflectionFunction($case['fname']); + $ret = $rf->getReturnType(); + assert( + $ret instanceof ReflectionUnionType, + "{$case['fname']}: expected ReflectionUnionType return", + ); + assert( + $ret->allowsNull() === $case['nullable'], + "{$case['fname']}: nullable mismatch", + ); + + $members = array_map( + static fn(ReflectionNamedType $t): string => $t->getName(), + $ret->getTypes(), + ); + sort($members); + assert( + $members === $case['members'], + "{$case['fname']}: expected " . implode('|', $case['members']) + . ", got " . implode('|', $members), + ); +} diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 6b494a64e..cc9ced3d4 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -17,8 +17,17 @@ pub fn build_module(module: ModuleBuilder) -> ModuleBuilder { module = integration::bool::build_module(module); module = integration::callable::build_module(module); module = integration::class::build_module(module); + module = integration::class_union::build_module(module); + #[cfg(php83)] + { + module = integration::intersection::build_module(module); + } module = integration::closure::build_module(module); module = integration::defaults::build_module(module); + #[cfg(php83)] + { + module = integration::dnf::build_module(module); + } #[cfg(feature = "enum")] { module = integration::enum_::build_module(module); @@ -36,9 +45,13 @@ pub fn build_module(module: ModuleBuilder) -> ModuleBuilder { module = integration::observer::build_module(module); } module = integration::persistent_string::build_module(module); + module = integration::php_types_attr::build_module(module); + module = integration::php_union::build_module(module); module = integration::reference::build_module(module); module = integration::separated::build_module(module); module = integration::string::build_module(module); + module = integration::typed_property::build_module(module); + module = integration::union::build_module(module); module = integration::variadic_args::build_module(module); module = integration::interface::build_module(module); diff --git a/unix_build.rs b/unix_build.rs index 58a834411..d85077d2b 100644 --- a/unix_build.rs +++ b/unix_build.rs @@ -63,9 +63,86 @@ impl<'a> PHPProvider<'a> for Provider<'a> { } fn print_extra_link_args(&self) -> Result<()> { - #[cfg(feature = "embed")] - println!("cargo:rustc-link-lib=php"); + // -lphp is opt-in: linking it into the production cdylib makes + // ld.so map a second copy of libphp at extension load, and + // function pointers like `zend_string_init_interned` read as NULL + // from that copy. Tests on hosts that have libphp opt in via + // EXT_PHP_RS_LINK_LIBPHP=1; the embed feature builds a standalone + // binary that always needs it. + // + // Some PHP layouts ship the CLI but not a shared libphp at + // /lib (Homebrew `php@x.y` NTS, `php@x.y-debug-zts`). When + // EXT_PHP_RS_LINK_LIBPHP=1 is set in those layouts we bail: relying on + // `-Wl,-undefined,dynamic_lookup` to defer symbol resolution worked on + // older macOS but not under chained fixups (default on macOS 13+ / + // ld-prime), where missing data symbols abort the test binary at load. + // The `embed` feature stays strict for the same reason. + let force_link = std::env::var_os("EXT_PHP_RS_LINK_LIBPHP").is_some_and(|v| v == "1"); + if cfg!(feature = "embed") { + let prefix = Self::php_config("--prefix")?.trim().to_owned(); + if !prefix.is_empty() { + println!("cargo:rustc-link-search=native={prefix}/lib"); + } + println!("cargo:rustc-link-lib=php"); + } else if force_link { + let prefix = Self::php_config("--prefix")?.trim().to_owned(); + let lib_dir = (!prefix.is_empty()).then(|| format!("{prefix}/lib")); + let libphp_exists = lib_dir + .as_ref() + .is_some_and(|dir| libphp_present_in(std::path::Path::new(dir))); + if !libphp_exists { + let searched_dir = lib_dir + .as_deref() + .unwrap_or(""); + bail!( + "EXT_PHP_RS_LINK_LIBPHP=1 was set but no libphp shared library was found in \ + {searched_dir}. Either install a libphp build at that path (for example via \ + `libphp{{version}}-embed` on Debian/Ubuntu, or a PHP build configured with \ + `--enable-embed`), point to it with `RUSTFLAGS=\"-L native=/path/to/libphp\"`, \ + or unset EXT_PHP_RS_LINK_LIBPHP and skip the test step on this host." + ); + } + if let Some(dir) = lib_dir { + println!("cargo:rustc-link-search=native={dir}"); + } + println!("cargo:rustc-link-lib=php"); + } + println!("cargo:rerun-if-env-changed=EXT_PHP_RS_LINK_LIBPHP"); Ok(()) } } + +fn libphp_present_in(dir: &std::path::Path) -> bool { + if !dir.is_dir() { + return false; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !name.starts_with("libphp") { + continue; + } + if has_shared_lib_extension(name) || name.contains(".so.") { + return true; + } + } + false +} + +fn has_shared_lib_extension(name: &str) -> bool { + std::path::Path::new(name) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + ext.eq_ignore_ascii_case("so") + || ext.eq_ignore_ascii_case("dylib") + || ext.eq_ignore_ascii_case("tbd") + || ext.eq_ignore_ascii_case("a") + }) +}