diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3a45a3b5cb..b74b981723 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,6 +80,7 @@ jobs: env: DOCS_RS: "" run: cargo clean && cargo build + build: name: Build and Test runs-on: ${{ matrix.os }} @@ -161,12 +162,12 @@ jobs: - name: Build env: EXT_PHP_RS_TEST: "" - run: cargo build --release --features closure,anyhow --all + run: cargo build --release --features closure,anyhow --workspace ${{ matrix.php == '8.0' && '--no-default-features' || '' }} # 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')" - run: cargo test --release --all --features closure,anyhow --no-fail-fast + run: cargo test --release --workspace --features closure,anyhow --no-fail-fast ${{ matrix.php == '8.0' && '--no-default-features' || '' }} build-zts: name: Build with ZTS runs-on: ubuntu-latest diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 09d93bfde7..e6bc17ed6c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -2,10 +2,10 @@ name: coverage on: push: branches-ignore: - - 'release-plz-*' + - "release-plz-*" pull_request: branches-ignore: - - 'release-plz-*' + - "release-plz-*" env: # increment this manually to force cache eviction RUST_CACHE_PREFIX: "v0-rust" @@ -63,6 +63,6 @@ jobs: cargo tarpaulin --version - name: Run tests run: | - cargo tarpaulin --engine llvm --workspace --all-features --tests --exclude tests --exclude-files docsrs_bindings.rs --timeout 120 --out Xml + cargo tarpaulin --engine llvm --workspace --all-features --tests --exclude tests --exclude-files docsrs_bindings.rs --exclude-files "crates/macros/tests/expand/*.expanded.rs" --timeout 120 --out Xml - name: Upload coverage uses: coverallsapp/github-action@v2 diff --git a/.lefthook.yml b/.lefthook.yml index 236e7d3da4..8d518ea070 100644 --- a/.lefthook.yml +++ b/.lefthook.yml @@ -17,8 +17,8 @@ pre-commit: The `docsrs_bindings.rs` file seems to be out of date. Please check the updated bindings in `docsrs_bindings.rs` and commit the changes. - name: "macro docs" - run: tools/update_lib_docs.sh && git diff --exit-code crates/macros/src/lib.rs - glob: "guide/src/macros/*.md" - fail_text: | - The macro crates documentation seems to be out of date. - Please check the updated documentation in `crates/macros/src/lib.rs` and commit the changes. + run: tools/update_lib_docs.sh + glob: + - "guide/src/macros/*.md" + - "crates/macros/src/lib.rs" + stage_fixed: true diff --git a/Cargo.toml b/Cargo.toml index bb8656886b..f480a6f2d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,9 +38,11 @@ native-tls = "0.2" zip = "4.0" [features] +default = ["enum"] closure = [] embed = [] anyhow = ["dep:anyhow"] +enum = [] [workspace] members = [ diff --git a/allowed_bindings.rs b/allowed_bindings.rs index ecf2800f49..81a2a78c65 100644 --- a/allowed_bindings.rs +++ b/allowed_bindings.rs @@ -87,6 +87,9 @@ bind! { zend_declare_class_constant, zend_declare_property, zend_do_implement_interface, + zend_enum_add_case, + zend_enum_get_case, + zend_enum_new, zend_execute_data, zend_function_entry, zend_hash_clean, @@ -114,6 +117,7 @@ bind! { zend_register_bool_constant, zend_register_double_constant, zend_register_ini_entries, + zend_register_internal_enum, zend_ini_entry_def, zend_register_internal_class_ex, zend_register_long_constant, @@ -191,6 +195,7 @@ bind! { ZEND_ACC_DEPRECATED, ZEND_ACC_DONE_PASS_TWO, ZEND_ACC_EARLY_BINDING, + ZEND_ACC_ENUM, ZEND_ACC_FAKE_CLOSURE, ZEND_ACC_FINAL, ZEND_ACC_GENERATOR, diff --git a/build.rs b/build.rs index e144eda655..9df8dae7d0 100644 --- a/build.rs +++ b/build.rs @@ -261,7 +261,7 @@ fn generate_bindings(defines: &[(&str, &str)], includes: &[PathBuf]) -> Result Self { - ApiVersion::Php80 + pub fn min() -> Self { + [ + ApiVersion::Php80, + #[cfg(feature = "enum")] + ApiVersion::Php81, + ] + .into_iter() + .max() + .unwrap_or(Self::max()) } /// Returns the maximum API version supported by ext-php-rs. diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index d02265e68f..49408ee2b2 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -11,7 +11,7 @@ edition = "2018" categories = ["api-bindings", "command-line-interface"] [dependencies] -ext-php-rs = { version = "0.14", path = "../../" } +ext-php-rs = { version = "0.14", default-features = false, path = "../../" } clap = { version = "4.0", features = ["derive"] } anyhow = "1" @@ -22,3 +22,7 @@ semver = "1.0" [lints.rust] missing_docs = "warn" + +[features] +default = ["enum"] +enum = ["ext-php-rs/enum"] diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml index 276830c3c0..0ad8803691 100644 --- a/crates/macros/Cargo.toml +++ b/crates/macros/Cargo.toml @@ -6,7 +6,7 @@ homepage = "https://github.com/davidcole1340/ext-php-rs" license = "MIT OR Apache-2.0" version = "0.11.2" authors = ["David Cole "] -edition = "2018" +edition = "2021" [lib] proc-macro = true @@ -19,6 +19,7 @@ proc-macro2 = "1.0.26" lazy_static = "1.4.0" anyhow = "1.0" convert_case = "0.8.0" +itertools = "0.14.0" [lints.rust] missing_docs = "warn" diff --git a/crates/macros/src/enum_.rs b/crates/macros/src/enum_.rs new file mode 100644 index 0000000000..d10ab89c91 --- /dev/null +++ b/crates/macros/src/enum_.rs @@ -0,0 +1,411 @@ +use std::convert::TryFrom; + +use darling::{util::Flag, FromAttributes}; +use itertools::Itertools; +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use syn::{Fields, Ident, ItemEnum, Lit}; + +use crate::{ + helpers::get_docs, + parsing::{PhpRename, RenameRule, Visibility}, + prelude::*, +}; + +#[derive(FromAttributes, Default, Debug)] +#[darling(default, attributes(php), forward_attrs(doc))] +struct PhpEnumAttribute { + #[darling(flatten)] + rename: PhpRename, + #[darling(default)] + allow_native_discriminants: Flag, + rename_cases: Option, + // TODO: Implement visibility support + vis: Option, + attrs: Vec, +} + +#[derive(FromAttributes, Default, Debug)] +#[darling(default, attributes(php), forward_attrs(doc))] +struct PhpEnumVariantAttribute { + #[darling(flatten)] + rename: PhpRename, + #[darling(rename = "value")] + discriminant: Option, + attrs: Vec, +} + +pub fn parser(mut input: ItemEnum) -> Result { + let php_attr = PhpEnumAttribute::from_attributes(&input.attrs)?; + input.attrs.retain(|attr| !attr.path().is_ident("php")); + + let docs = get_docs(&php_attr.attrs)?; + let mut cases = vec![]; + let mut discriminant_type = DiscriminantType::None; + + for variant in &mut input.variants { + if variant.fields != Fields::Unit { + bail!("Enum cases must be unit variants, found: {:?}", variant); + } + if !php_attr.allow_native_discriminants.is_present() && variant.discriminant.is_some() { + bail!(variant => "Native discriminants are currently not exported to PHP. To set a discriminant, use the `#[php(allow_native_discriminants)]` attribute on the enum. To export discriminants, set the #[php(value = ...)] attribute on the enum case."); + } + + let variant_attr = PhpEnumVariantAttribute::from_attributes(&variant.attrs)?; + variant.attrs.retain(|attr| !attr.path().is_ident("php")); + let docs = get_docs(&variant_attr.attrs)?; + let discriminant = variant_attr + .discriminant + .as_ref() + .map(TryInto::try_into) + .transpose()?; + + if let Some(d) = &discriminant { + match d { + Discriminant::String(_) => { + if discriminant_type == DiscriminantType::Integer { + bail!(variant => "Mixed discriminants are not allowed in enums, found string and integer discriminants"); + } + + discriminant_type = DiscriminantType::String; + } + Discriminant::Integer(_) => { + if discriminant_type == DiscriminantType::String { + bail!(variant => "Mixed discriminants are not allowed in enums, found string and integer discriminants"); + } + + discriminant_type = DiscriminantType::Integer; + } + } + } else if discriminant_type != DiscriminantType::None { + bail!(variant => "Discriminant must be specified for all enum cases, found: {:?}", variant); + } + + cases.push(EnumCase { + ident: variant.ident.clone(), + name: variant_attr.rename.rename( + variant.ident.to_string(), + php_attr.rename_cases.unwrap_or(RenameRule::Pascal), + ), + attrs: variant_attr, + discriminant, + docs, + }); + + if !cases + .iter() + .filter_map(|case| case.discriminant.as_ref()) + .all_unique() + { + bail!(variant => "Enum cases must have unique discriminants, found duplicates in: {:?}", cases); + } + } + + let enum_props = Enum::new( + &input.ident, + &php_attr, + docs, + cases, + None, // TODO: Implement flags support + discriminant_type, + ); + + Ok(quote! { + #[allow(dead_code)] + #input + + #enum_props + }) +} + +#[derive(Debug)] +pub struct Enum<'a> { + ident: &'a Ident, + name: String, + discriminant_type: DiscriminantType, + docs: Vec, + cases: Vec, + flags: Option, +} + +impl<'a> Enum<'a> { + fn new( + ident: &'a Ident, + attrs: &PhpEnumAttribute, + docs: Vec, + cases: Vec, + flags: Option, + discriminant_type: DiscriminantType, + ) -> Self { + let name = attrs.rename.rename(ident.to_string(), RenameRule::Pascal); + + Self { + ident, + name, + discriminant_type, + docs, + cases, + flags, + } + } + + fn registered_class(&self) -> TokenStream { + let ident = &self.ident; + let name = &self.name; + let flags = self + .flags + .as_ref() + .map(|f| quote! { | #f }) + .unwrap_or_default(); + let flags = quote! { ::ext_php_rs::flags::ClassFlags::Enum #flags }; + let docs = &self.docs; + + quote! { + impl ::ext_php_rs::class::RegisteredClass for #ident { + const CLASS_NAME: &'static str = #name; + const BUILDER_MODIFIER: ::std::option::Option< + fn(::ext_php_rs::builders::ClassBuilder) -> ::ext_php_rs::builders::ClassBuilder + > = None; + const EXTENDS: ::std::option::Option< + ::ext_php_rs::class::ClassEntryInfo + > = None; + const IMPLEMENTS: &'static [::ext_php_rs::class::ClassEntryInfo] = &[]; + const FLAGS: ::ext_php_rs::flags::ClassFlags = #flags; + const DOC_COMMENTS: &'static [&'static str] = &[ + #(#docs,)* + ]; + + fn get_metadata() -> &'static ::ext_php_rs::class::ClassMetadata { + static METADATA: ::ext_php_rs::class::ClassMetadata<#ident> = + ::ext_php_rs::class::ClassMetadata::new(); + &METADATA + } + + #[inline] + fn get_properties<'a>() -> ::std::collections::HashMap< + &'static str, ::ext_php_rs::internal::property::PropertyInfo<'a, Self> + > { + ::std::collections::HashMap::new() + } + + #[inline] + fn method_builders() -> ::std::vec::Vec< + (::ext_php_rs::builders::FunctionBuilder<'static>, ::ext_php_rs::flags::MethodFlags) + > { + use ::ext_php_rs::internal::class::PhpClassImpl; + ::ext_php_rs::internal::class::PhpClassImplCollector::::default().get_methods() + } + + #[inline] + fn constructor() -> ::std::option::Option<::ext_php_rs::class::ConstructorMeta> { + None + } + + #[inline] + fn constants() -> &'static [(&'static str, &'static dyn ::ext_php_rs::convert::IntoZvalDyn, &'static [&'static str])] { + use ::ext_php_rs::internal::class::PhpClassImpl; + ::ext_php_rs::internal::class::PhpClassImplCollector::::default().get_constants() + } + } + } + } + + fn registered_enum(&self) -> TokenStream { + let ident = &self.ident; + let cases = &self.cases; + let case_from_names = self.cases.iter().map(|case| { + let ident = &case.ident; + let name = &case.name; + quote! { + #name => Ok(Self::#ident) + } + }); + let case_to_names = self.cases.iter().map(|case| { + let ident = &case.ident; + let name = &case.name; + quote! { + Self::#ident => #name + } + }); + + quote! { + impl ::ext_php_rs::enum_::RegisteredEnum for #ident { + const CASES: &'static [::ext_php_rs::enum_::EnumCase] = &[ + #(#cases,)* + ]; + + fn from_name(name: &str) -> ::ext_php_rs::error::Result { + match name { + #(#case_from_names,)* + _ => Err(::ext_php_rs::error::Error::InvalidProperty), + } + } + + fn to_name(&self) -> &'static str { + match self { + #(#case_to_names,)* + } + } + } + } + } + + pub fn impl_try_from(&self) -> TokenStream { + if self.discriminant_type == DiscriminantType::None { + return quote! {}; + } + let discriminant_type = match self.discriminant_type { + DiscriminantType::Integer => quote! { i64 }, + DiscriminantType::String => quote! { &str }, + DiscriminantType::None => unreachable!("Discriminant type should not be None here"), + }; + let ident = &self.ident; + let cases = self.cases.iter().map(|case| { + let ident = &case.ident; + match case + .discriminant + .as_ref() + .expect("Discriminant should be set") + { + Discriminant::String(s) => quote! { #s => Ok(Self::#ident) }, + Discriminant::Integer(i) => quote! { #i => Ok(Self::#ident) }, + } + }); + + quote! { + impl TryFrom<#discriminant_type> for #ident { + type Error = ::ext_php_rs::error::Error; + + fn try_from(value: #discriminant_type) -> ::ext_php_rs::error::Result { + match value { + #( + #cases, + )* + _ => Err(::ext_php_rs::error::Error::InvalidProperty), + } + } + } + } + } + + pub fn impl_into(&self) -> TokenStream { + if self.discriminant_type == DiscriminantType::None { + return quote! {}; + } + let discriminant_type = match self.discriminant_type { + DiscriminantType::Integer => quote! { i64 }, + DiscriminantType::String => quote! { &'static str }, + DiscriminantType::None => unreachable!("Discriminant type should not be None here"), + }; + let ident = &self.ident; + let cases = self.cases.iter().map(|case| { + let ident = &case.ident; + match case + .discriminant + .as_ref() + .expect("Discriminant should be set") + { + Discriminant::String(s) => quote! { Self::#ident => #s }, + Discriminant::Integer(i) => quote! { Self::#ident => #i }, + } + }); + + quote! { + impl Into<#discriminant_type> for #ident { + fn into(self) -> #discriminant_type { + match self { + #( + #cases, + )* + } + } + } + } + } +} + +impl ToTokens for Enum<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let class = self.registered_class(); + let enum_impl = self.registered_enum(); + let impl_try_from = self.impl_try_from(); + let impl_into = self.impl_into(); + + tokens.extend(quote! { + #class + #enum_impl + #impl_try_from + #impl_into + }); + } +} + +#[derive(Debug)] +struct EnumCase { + ident: Ident, + name: String, + #[allow(dead_code)] + attrs: PhpEnumVariantAttribute, + discriminant: Option, + docs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum Discriminant { + String(String), + Integer(i64), +} + +impl TryFrom<&Lit> for Discriminant { + type Error = syn::Error; + + fn try_from(lit: &Lit) -> Result { + match lit { + Lit::Str(s) => Ok(Discriminant::String(s.value())), + Lit::Int(i) => i + .base10_parse::() + .map(Discriminant::Integer) + .map_err(|_| err!(lit => "Invalid integer literal for enum case: {:?}", lit)), + _ => bail!(lit => "Unsupported discriminant type: {:?}", lit), + } + } +} + +impl ToTokens for Discriminant { + fn to_tokens(&self, tokens: &mut TokenStream) { + tokens.extend(match self { + Discriminant::String(s) => { + quote! { ::ext_php_rs::enum_::Discriminant::String(#s) } + } + Discriminant::Integer(i) => { + quote! { ::ext_php_rs::enum_::Discriminant::Int(#i) } + } + }); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DiscriminantType { + None, + String, + Integer, +} + +impl ToTokens for EnumCase { + fn to_tokens(&self, tokens: &mut TokenStream) { + let ident = &self.name; + let discriminant = self + .discriminant + .as_ref() + .map_or_else(|| quote! { None }, |v| quote! { Some(#v) }); + let docs = &self.docs; + + tokens.extend(quote! { + ::ext_php_rs::enum_::EnumCase { + name: #ident, + discriminant: #discriminant, + docs: &[#(#docs,)*], + } + }); + } +} diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index d058018916..8b7cdbee04 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -1,6 +1,7 @@ //! Macros for the `php-ext` crate. mod class; mod constant; +mod enum_; mod extern_; mod fastcall; mod function; @@ -13,7 +14,7 @@ mod zval; use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; -use syn::{DeriveInput, ItemConst, ItemFn, ItemForeignMod, ItemImpl, ItemStruct}; +use syn::{DeriveInput, ItemConst, ItemEnum, ItemFn, ItemForeignMod, ItemImpl, ItemStruct}; extern crate proc_macro; @@ -215,6 +216,129 @@ fn php_class_internal(args: TokenStream2, input: TokenStream2) -> TokenStream2 { class::parser(input).unwrap_or_else(|e| e.to_compile_error()) } +// BEGIN DOCS FROM enum.md +/// # `#[php_enum]` Attribute +/// +/// Enums can be exported to PHP as enums with the `#[php_enum]` attribute +/// macro. This attribute derives the `RegisteredClass` and `PhpEnum` traits on +/// your enum. To register the enum use the `enumeration::()` method +/// on the `ModuleBuilder` in the `#[php_module]` macro. +/// +/// ## Options +/// +/// The `#[php_enum]` attribute can be configured with the following options: +/// - `#[php(name = "EnumName")]` or `#[php(change_case = snake_case)]`: Sets +/// the name of the enum in PHP. The default is the `PascalCase` name of the +/// enum. +/// - `#[php(allow_native_discriminants)]`: Allows the use of native Rust +/// discriminants (e.g., `Hearts = 1`). +/// +/// The cases of the enum can be configured with the following options: +/// - `#[php(name = "CaseName")]` or `#[php(change_case = snake_case)]`: Sets +/// the name of the enum case in PHP. The default is the `PascalCase` name of +/// the case. +/// - `#[php(value = "value")]` or `#[php(value = 123)]`: Sets the discriminant +/// value for the enum case. This can be a string or an integer. If not set, +/// the case will be exported as a simple enum case without a discriminant. +/// +/// ### Example +/// +/// This example creates a PHP enum `Suit`. +/// +/// ```rust,no_run,ignore +/// # #![cfg_attr(windows, feature(abi_vectorcall))] +/// # extern crate ext_php_rs; +/// use ext_php_rs::prelude::*; +/// +/// #[php_enum] +/// pub enum Suit { +/// Hearts, +/// Diamonds, +/// Clubs, +/// Spades, +/// } +/// +/// #[php_module] +/// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { +/// module.enumeration::() +/// } +/// # fn main() {} +/// ``` +/// +/// ## Backed Enums +/// Enums can also be backed by either `i64` or `&'static str`. Those values can +/// be set using the `#[php(value = "value")]` or `#[php(value = 123)]` +/// attributes on the enum variants. +/// +/// All variants must have a value of the same type, either all `i64` or all +/// `&'static str`. +/// +/// ```rust,no_run,ignore +/// # #![cfg_attr(windows, feature(abi_vectorcall))] +/// # extern crate ext_php_rs; +/// use ext_php_rs::prelude::*; +/// +/// #[php_enum] +/// pub enum Suit { +/// #[php(value = "hearts")] +/// Hearts, +/// #[php(value = "diamonds")] +/// Diamonds, +/// #[php(value = "clubs")] +/// Clubs, +/// #[php(value = "spades")] +/// Spades, +/// } +/// #[php_module] +/// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { +/// module.enumeration::() +/// } +/// # fn main() {} +/// ``` +/// +/// ### 'Native' Discriminators +/// Native rust discriminants are currently not supported and will not be +/// exported to PHP. +/// +/// To avoid confusion a compiler error will be raised if you try to use a +/// native discriminant. You can ignore this error by adding the +/// `#[php(allow_native_discriminants)]` attribute to your enum. +/// +/// ```rust,no_run,ignore +/// # #![cfg_attr(windows, feature(abi_vectorcall))] +/// # extern crate ext_php_rs; +/// use ext_php_rs::prelude::*; +/// +/// #[php_enum] +/// #[php(allow_native_discriminants)] +/// pub enum Suit { +/// Hearts = 1, +/// Diamonds = 2, +/// Clubs = 3, +/// Spades = 4, +/// } +/// +/// #[php_module] +/// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { +/// module.enumeration::() +/// } +/// # fn main() {} +/// ``` +/// +/// +/// TODO: Add backed enums example +// END DOCS FROM enum.md +#[proc_macro_attribute] +pub fn php_enum(args: TokenStream, input: TokenStream) -> TokenStream { + php_enum_internal(args.into(), input.into()).into() +} + +fn php_enum_internal(_args: TokenStream2, input: TokenStream2) -> TokenStream2 { + let input = parse_macro_input2!(input as ItemEnum); + + enum_::parser(input).unwrap_or_else(|e| e.to_compile_error()) +} + // BEGIN DOCS FROM function.md /// # `#[php_function]` Attribute /// @@ -1134,6 +1258,7 @@ mod tests { &[ ("php_class", php_class_internal as AttributeFn), ("php_const", php_const_internal as AttributeFn), + ("php_enum", php_enum_internal as AttributeFn), ("php_extern", php_extern_internal as AttributeFn), ("php_function", php_function_internal as AttributeFn), ("php_impl", php_impl_internal as AttributeFn), diff --git a/crates/macros/tests/expand/enum.expanded.rs b/crates/macros/tests/expand/enum.expanded.rs new file mode 100644 index 0000000000..546ad77547 --- /dev/null +++ b/crates/macros/tests/expand/enum.expanded.rs @@ -0,0 +1,294 @@ +#[macro_use] +extern crate ext_php_rs_derive; +#[allow(dead_code)] +/// Doc comments for MyEnum. +/// This is a basic enum example. +enum MyEnum { + /// Variant1 of MyEnum. + /// This variant represents the first case. + Variant1, + Variant2, + /// Variant3 of MyEnum. + Variant3, +} +impl ::ext_php_rs::class::RegisteredClass for MyEnum { + const CLASS_NAME: &'static str = "MyEnum"; + const BUILDER_MODIFIER: ::std::option::Option< + fn(::ext_php_rs::builders::ClassBuilder) -> ::ext_php_rs::builders::ClassBuilder, + > = None; + const EXTENDS: ::std::option::Option<::ext_php_rs::class::ClassEntryInfo> = None; + const IMPLEMENTS: &'static [::ext_php_rs::class::ClassEntryInfo] = &[]; + const FLAGS: ::ext_php_rs::flags::ClassFlags = ::ext_php_rs::flags::ClassFlags::Enum; + const DOC_COMMENTS: &'static [&'static str] = &[ + " Doc comments for MyEnum.", + " This is a basic enum example.", + ]; + fn get_metadata() -> &'static ::ext_php_rs::class::ClassMetadata { + static METADATA: ::ext_php_rs::class::ClassMetadata = ::ext_php_rs::class::ClassMetadata::new(); + &METADATA + } + #[inline] + fn get_properties<'a>() -> ::std::collections::HashMap< + &'static str, + ::ext_php_rs::internal::property::PropertyInfo<'a, Self>, + > { + ::std::collections::HashMap::new() + } + #[inline] + fn method_builders() -> ::std::vec::Vec< + ( + ::ext_php_rs::builders::FunctionBuilder<'static>, + ::ext_php_rs::flags::MethodFlags, + ), + > { + use ::ext_php_rs::internal::class::PhpClassImpl; + ::ext_php_rs::internal::class::PhpClassImplCollector::::default() + .get_methods() + } + #[inline] + fn constructor() -> ::std::option::Option< + ::ext_php_rs::class::ConstructorMeta, + > { + None + } + #[inline] + fn constants() -> &'static [( + &'static str, + &'static dyn ::ext_php_rs::convert::IntoZvalDyn, + &'static [&'static str], + )] { + use ::ext_php_rs::internal::class::PhpClassImpl; + ::ext_php_rs::internal::class::PhpClassImplCollector::::default() + .get_constants() + } +} +impl ::ext_php_rs::enum_::RegisteredEnum for MyEnum { + const CASES: &'static [::ext_php_rs::enum_::EnumCase] = &[ + ::ext_php_rs::enum_::EnumCase { + name: "Variant1", + discriminant: None, + docs: &[" Variant1 of MyEnum.", " This variant represents the first case."], + }, + ::ext_php_rs::enum_::EnumCase { + name: "Variant_2", + discriminant: None, + docs: &[], + }, + ::ext_php_rs::enum_::EnumCase { + name: "VARIANT_3", + discriminant: None, + docs: &[" Variant3 of MyEnum."], + }, + ]; + fn from_name(name: &str) -> ::ext_php_rs::error::Result { + match name { + "Variant1" => Ok(Self::Variant1), + "Variant_2" => Ok(Self::Variant2), + "VARIANT_3" => Ok(Self::Variant3), + _ => Err(::ext_php_rs::error::Error::InvalidProperty), + } + } + fn to_name(&self) -> &'static str { + match self { + Self::Variant1 => "Variant1", + Self::Variant2 => "Variant_2", + Self::Variant3 => "VARIANT_3", + } + } +} +#[allow(dead_code)] +enum MyEnumWithIntValues { + Variant1, + Variant2, +} +impl ::ext_php_rs::class::RegisteredClass for MyEnumWithIntValues { + const CLASS_NAME: &'static str = "MyIntValuesEnum"; + const BUILDER_MODIFIER: ::std::option::Option< + fn(::ext_php_rs::builders::ClassBuilder) -> ::ext_php_rs::builders::ClassBuilder, + > = None; + const EXTENDS: ::std::option::Option<::ext_php_rs::class::ClassEntryInfo> = None; + const IMPLEMENTS: &'static [::ext_php_rs::class::ClassEntryInfo] = &[]; + const FLAGS: ::ext_php_rs::flags::ClassFlags = ::ext_php_rs::flags::ClassFlags::Enum; + const DOC_COMMENTS: &'static [&'static str] = &[]; + fn get_metadata() -> &'static ::ext_php_rs::class::ClassMetadata { + static METADATA: ::ext_php_rs::class::ClassMetadata = ::ext_php_rs::class::ClassMetadata::new(); + &METADATA + } + #[inline] + fn get_properties<'a>() -> ::std::collections::HashMap< + &'static str, + ::ext_php_rs::internal::property::PropertyInfo<'a, Self>, + > { + ::std::collections::HashMap::new() + } + #[inline] + fn method_builders() -> ::std::vec::Vec< + ( + ::ext_php_rs::builders::FunctionBuilder<'static>, + ::ext_php_rs::flags::MethodFlags, + ), + > { + use ::ext_php_rs::internal::class::PhpClassImpl; + ::ext_php_rs::internal::class::PhpClassImplCollector::::default() + .get_methods() + } + #[inline] + fn constructor() -> ::std::option::Option< + ::ext_php_rs::class::ConstructorMeta, + > { + None + } + #[inline] + fn constants() -> &'static [( + &'static str, + &'static dyn ::ext_php_rs::convert::IntoZvalDyn, + &'static [&'static str], + )] { + use ::ext_php_rs::internal::class::PhpClassImpl; + ::ext_php_rs::internal::class::PhpClassImplCollector::::default() + .get_constants() + } +} +impl ::ext_php_rs::enum_::RegisteredEnum for MyEnumWithIntValues { + const CASES: &'static [::ext_php_rs::enum_::EnumCase] = &[ + ::ext_php_rs::enum_::EnumCase { + name: "Variant1", + discriminant: Some(::ext_php_rs::enum_::Discriminant::Int(1i64)), + docs: &[], + }, + ::ext_php_rs::enum_::EnumCase { + name: "Variant2", + discriminant: Some(::ext_php_rs::enum_::Discriminant::Int(42i64)), + docs: &[], + }, + ]; + fn from_name(name: &str) -> ::ext_php_rs::error::Result { + match name { + "Variant1" => Ok(Self::Variant1), + "Variant2" => Ok(Self::Variant2), + _ => Err(::ext_php_rs::error::Error::InvalidProperty), + } + } + fn to_name(&self) -> &'static str { + match self { + Self::Variant1 => "Variant1", + Self::Variant2 => "Variant2", + } + } +} +impl TryFrom for MyEnumWithIntValues { + type Error = ::ext_php_rs::error::Error; + fn try_from(value: i64) -> ::ext_php_rs::error::Result { + match value { + 1i64 => Ok(Self::Variant1), + 42i64 => Ok(Self::Variant2), + _ => Err(::ext_php_rs::error::Error::InvalidProperty), + } + } +} +impl Into for MyEnumWithIntValues { + fn into(self) -> i64 { + match self { + Self::Variant1 => 1i64, + Self::Variant2 => 42i64, + } + } +} +#[allow(dead_code)] +enum MyEnumWithStringValues { + Variant1, + Variant2, +} +impl ::ext_php_rs::class::RegisteredClass for MyEnumWithStringValues { + const CLASS_NAME: &'static str = "MY_ENUM_WITH_STRING_VALUES"; + const BUILDER_MODIFIER: ::std::option::Option< + fn(::ext_php_rs::builders::ClassBuilder) -> ::ext_php_rs::builders::ClassBuilder, + > = None; + const EXTENDS: ::std::option::Option<::ext_php_rs::class::ClassEntryInfo> = None; + const IMPLEMENTS: &'static [::ext_php_rs::class::ClassEntryInfo] = &[]; + const FLAGS: ::ext_php_rs::flags::ClassFlags = ::ext_php_rs::flags::ClassFlags::Enum; + const DOC_COMMENTS: &'static [&'static str] = &[]; + fn get_metadata() -> &'static ::ext_php_rs::class::ClassMetadata { + static METADATA: ::ext_php_rs::class::ClassMetadata = ::ext_php_rs::class::ClassMetadata::new(); + &METADATA + } + #[inline] + fn get_properties<'a>() -> ::std::collections::HashMap< + &'static str, + ::ext_php_rs::internal::property::PropertyInfo<'a, Self>, + > { + ::std::collections::HashMap::new() + } + #[inline] + fn method_builders() -> ::std::vec::Vec< + ( + ::ext_php_rs::builders::FunctionBuilder<'static>, + ::ext_php_rs::flags::MethodFlags, + ), + > { + use ::ext_php_rs::internal::class::PhpClassImpl; + ::ext_php_rs::internal::class::PhpClassImplCollector::::default() + .get_methods() + } + #[inline] + fn constructor() -> ::std::option::Option< + ::ext_php_rs::class::ConstructorMeta, + > { + None + } + #[inline] + fn constants() -> &'static [( + &'static str, + &'static dyn ::ext_php_rs::convert::IntoZvalDyn, + &'static [&'static str], + )] { + use ::ext_php_rs::internal::class::PhpClassImpl; + ::ext_php_rs::internal::class::PhpClassImplCollector::::default() + .get_constants() + } +} +impl ::ext_php_rs::enum_::RegisteredEnum for MyEnumWithStringValues { + const CASES: &'static [::ext_php_rs::enum_::EnumCase] = &[ + ::ext_php_rs::enum_::EnumCase { + name: "Variant1", + discriminant: Some(::ext_php_rs::enum_::Discriminant::String("foo")), + docs: &[], + }, + ::ext_php_rs::enum_::EnumCase { + name: "Variant2", + discriminant: Some(::ext_php_rs::enum_::Discriminant::String("bar")), + docs: &[], + }, + ]; + fn from_name(name: &str) -> ::ext_php_rs::error::Result { + match name { + "Variant1" => Ok(Self::Variant1), + "Variant2" => Ok(Self::Variant2), + _ => Err(::ext_php_rs::error::Error::InvalidProperty), + } + } + fn to_name(&self) -> &'static str { + match self { + Self::Variant1 => "Variant1", + Self::Variant2 => "Variant2", + } + } +} +impl TryFrom<&str> for MyEnumWithStringValues { + type Error = ::ext_php_rs::error::Error; + fn try_from(value: &str) -> ::ext_php_rs::error::Result { + match value { + "foo" => Ok(Self::Variant1), + "bar" => Ok(Self::Variant2), + _ => Err(::ext_php_rs::error::Error::InvalidProperty), + } + } +} +impl Into<&'static str> for MyEnumWithStringValues { + fn into(self) -> &'static str { + match self { + Self::Variant1 => "foo", + Self::Variant2 => "bar", + } + } +} diff --git a/crates/macros/tests/expand/enum.rs b/crates/macros/tests/expand/enum.rs new file mode 100644 index 0000000000..50c9b77a53 --- /dev/null +++ b/crates/macros/tests/expand/enum.rs @@ -0,0 +1,34 @@ +#[macro_use] +extern crate ext_php_rs_derive; + +/// Doc comments for MyEnum. +/// This is a basic enum example. +#[php_enum] +enum MyEnum { + /// Variant1 of MyEnum. + /// This variant represents the first case. + Variant1, + #[php(name = "Variant_2")] + Variant2, + /// Variant3 of MyEnum. + #[php(change_case = "UPPER_CASE")] + Variant3, +} + +#[php_enum] +#[php(name = "MyIntValuesEnum")] +enum MyEnumWithIntValues { + #[php(value = 1)] + Variant1, + #[php(value = 42)] + Variant2, +} + +#[php_enum] +#[php(change_case = "UPPER_CASE")] +enum MyEnumWithStringValues { + #[php(value = "foo")] + Variant1, + #[php(value = "bar")] + Variant2, +} diff --git a/docsrs_bindings.rs b/docsrs_bindings.rs index 900390cd7e..e23a8302d5 100644 --- a/docsrs_bindings.rs +++ b/docsrs_bindings.rs @@ -146,6 +146,7 @@ pub const ZEND_ACC_PROMOTED: u32 = 256; pub const ZEND_ACC_INTERFACE: u32 = 1; pub const ZEND_ACC_TRAIT: u32 = 2; pub const ZEND_ACC_ANON_CLASS: u32 = 4; +pub const ZEND_ACC_ENUM: u32 = 268435456; pub const ZEND_ACC_LINKED: u32 = 8; pub const ZEND_ACC_IMPLICIT_ABSTRACT_CLASS: u32 = 16; pub const ZEND_ACC_USE_GUARDS: u32 = 2048; @@ -2700,6 +2701,34 @@ pub struct php_file_globals { extern "C" { pub static mut file_globals: php_file_globals; } +extern "C" { + pub fn zend_enum_new( + result: *mut zval, + ce: *mut zend_class_entry, + case_name: *mut zend_string, + backing_value_zv: *mut zval, + ) -> *mut zend_object; +} +extern "C" { + pub fn zend_register_internal_enum( + name: *const ::std::os::raw::c_char, + type_: u8, + functions: *const zend_function_entry, + ) -> *mut zend_class_entry; +} +extern "C" { + pub fn zend_enum_add_case( + ce: *mut zend_class_entry, + case_name: *mut zend_string, + value: *mut zval, + ); +} +extern "C" { + pub fn zend_enum_get_case( + ce: *mut zend_class_entry, + name: *mut zend_string, + ) -> *mut zend_object; +} extern "C" { pub static mut zend_ce_throwable: *mut zend_class_entry; } diff --git a/guide/src/macros/enum.md b/guide/src/macros/enum.md new file mode 100644 index 0000000000..0c29e8b943 --- /dev/null +++ b/guide/src/macros/enum.md @@ -0,0 +1,102 @@ +# `#[php_enum]` Attribute + +Enums can be exported to PHP as enums with the `#[php_enum]` attribute macro. +This attribute derives the `RegisteredClass` and `PhpEnum` traits on your enum. +To register the enum use the `enumeration::()` method on the `ModuleBuilder` +in the `#[php_module]` macro. + +## Options + +The `#[php_enum]` attribute can be configured with the following options: +- `#[php(name = "EnumName")]` or `#[php(change_case = snake_case)]`: Sets the name of the enum in PHP. + The default is the `PascalCase` name of the enum. +- `#[php(allow_native_discriminants)]`: Allows the use of native Rust discriminants (e.g., `Hearts = 1`). + +The cases of the enum can be configured with the following options: +- `#[php(name = "CaseName")]` or `#[php(change_case = snake_case)]`: Sets the name of the enum case in PHP. + The default is the `PascalCase` name of the case. +- `#[php(value = "value")]` or `#[php(value = 123)]`: Sets the discriminant value for the enum case. + This can be a string or an integer. If not set, the case will be exported as a simple enum case without a discriminant. + +### Example + +This example creates a PHP enum `Suit`. + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; + +#[php_enum] +pub enum Suit { + Hearts, + Diamonds, + Clubs, + Spades, +} + +#[php_module] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module.enumeration::() +} +# fn main() {} +``` + +## Backed Enums +Enums can also be backed by either `i64` or `&'static str`. Those values can be set using the +`#[php(value = "value")]` or `#[php(value = 123)]` attributes on the enum variants. + +All variants must have a value of the same type, either all `i64` or all `&'static str`. + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; + +#[php_enum] +pub enum Suit { + #[php(value = "hearts")] + Hearts, + #[php(value = "diamonds")] + Diamonds, + #[php(value = "clubs")] + Clubs, + #[php(value = "spades")] + Spades, +} +#[php_module] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module.enumeration::() +} +# fn main() {} +``` + +### 'Native' Discriminators +Native rust discriminants are currently not supported and will not be exported to PHP. + +To avoid confusion a compiler error will be raised if you try to use a native discriminant. +You can ignore this error by adding the `#[php(allow_native_discriminants)]` attribute to your enum. + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +use ext_php_rs::prelude::*; + +#[php_enum] +#[php(allow_native_discriminants)] +pub enum Suit { + Hearts = 1, + Diamonds = 2, + Clubs = 3, + Spades = 4, +} + +#[php_module] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module.enumeration::() +} +# fn main() {} +``` + + +TODO: Add backed enums example diff --git a/guide/src/macros/php.md b/guide/src/macros/php.md index 0e52ff0117..63d5f107b3 100644 --- a/guide/src/macros/php.md +++ b/guide/src/macros/php.md @@ -33,24 +33,26 @@ fn hello_world(a: i32, b: i32) -> i32 { Which attributes are available depends on the element you are annotating: -| Attribute | `const` | `fn` | `struct` | `struct` field | `impl` | `impl` `const` | `impl` `fn` | -| -------------------- | ------- | ---- | -------- | -------------- | ------ | -------------- | ----------- | -| name | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | -| change_case | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | -| change_method_case | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | -| change_constant_case | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | -| flags | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | -| prop | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | -| extends | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | -| implements | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | -| modifier | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | -| defaults | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | -| optional | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | -| vis | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | -| getter | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | -| setter | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | -| constructor | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | -| abstract_method | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | +| Attribute | `const` | `fn` | `struct` | `struct` field | `impl` | `impl` `const` | `impl` `fn` | `enum` | `enum` case | +| -------------------------- | ------- | ---- | -------- | -------------- | ------ | -------------- | ----------- | ------ | ----------- | +| name | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | +| change_case | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | +| change_method_case | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| change_constant_case | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| flags | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| prop | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| extends | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| implements | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| modifier | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| defaults | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| optional | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| vis | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| getter | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| setter | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| constructor | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| abstract_method | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | +| allow_native_discriminants | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| discriminant | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ## `name` and `change_case` diff --git a/src/builders/enum_builder.rs b/src/builders/enum_builder.rs new file mode 100644 index 0000000000..a56379cd20 --- /dev/null +++ b/src/builders/enum_builder.rs @@ -0,0 +1,213 @@ +use std::{ffi::CString, ptr}; + +use crate::{ + builders::FunctionBuilder, + convert::IntoZval, + describe::DocComments, + enum_::{Discriminant, EnumCase}, + error::Result, + ffi::{zend_enum_add_case, zend_register_internal_enum}, + flags::{DataType, MethodFlags}, + types::{ZendStr, Zval}, + zend::{ClassEntry, FunctionEntry}, +}; + +/// A builder for PHP enums. +#[must_use] +pub struct EnumBuilder { + pub(crate) name: String, + pub(crate) methods: Vec<(FunctionBuilder<'static>, MethodFlags)>, + pub(crate) cases: Vec<&'static EnumCase>, + pub(crate) datatype: DataType, + register: Option, + pub(crate) docs: DocComments, +} + +impl EnumBuilder { + /// Creates a new enum builder with the given name. + pub fn new>(name: T) -> Self { + Self { + name: name.into(), + methods: Vec::default(), + cases: Vec::default(), + datatype: DataType::Undef, + register: None, + docs: DocComments::default(), + } + } + + /// Adds an enum case to the enum. + /// + /// # Panics + /// + /// If the case's data type does not match the enum's data type + pub fn case(mut self, case: &'static EnumCase) -> Self { + let data_type = case.data_type(); + assert!( + data_type == self.datatype || self.cases.is_empty(), + "Cannot add case with data type {:?} to enum with data type {:?}", + data_type, + self.datatype + ); + + self.datatype = data_type; + self.cases.push(case); + + self + } + + /// Adds a method to the enum. + pub fn method(mut self, method: FunctionBuilder<'static>, flags: MethodFlags) -> Self { + self.methods.push((method, flags)); + self + } + + /// Function to register the class with PHP. This function is called after + /// the class is built. + /// + /// # Parameters + /// + /// * `register` - The function to call to register the class. + pub fn registration(mut self, register: fn(&'static mut ClassEntry)) -> Self { + self.register = Some(register); + self + } + + /// Add documentation comments to the enum. + pub fn docs(mut self, docs: DocComments) -> Self { + self.docs = docs; + self + } + + /// Registers the enum with PHP. + /// + /// # Panics + /// + /// If the registration function was not set prior to calling this + /// method. + /// + /// # Errors + /// + /// If the enum could not be registered, e.g. due to an invalid name or + /// data type. + pub fn register(self) -> Result<()> { + let mut methods = self + .methods + .into_iter() + .map(|(method, flags)| { + method.build().map(|mut method| { + method.flags |= flags.bits(); + method + }) + }) + .collect::>>()?; + methods.push(FunctionEntry::end()); + + let class = unsafe { + zend_register_internal_enum( + CString::new(self.name)?.as_ptr(), + self.datatype.as_u32().try_into()?, + methods.into_boxed_slice().as_ptr(), + ) + }; + + for case in self.cases { + let name = ZendStr::new_interned(case.name, true); + let value = match &case.discriminant { + Some(value) => Self::create_enum_value(value)?, + None => ptr::null_mut(), + }; + unsafe { + zend_enum_add_case(class, name.into_raw(), value); + } + } + + if let Some(register) = self.register { + register(unsafe { &mut *class }); + } else { + panic!("Enum was not registered with a registration function",); + } + + Ok(()) + } + + fn create_enum_value(discriminant: &Discriminant) -> Result<*mut Zval> { + let value: Zval = match discriminant { + Discriminant::Int(i) => i.into_zval(false)?, + Discriminant::String(s) => s.into_zval(true)?, + }; + + let boxed_value = Box::new(value); + Ok(Box::into_raw(boxed_value).cast()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::enum_::Discriminant; + + const case1: EnumCase = EnumCase { + name: "Variant1", + discriminant: None, + docs: &[], + }; + const case2: EnumCase = EnumCase { + name: "Variant2", + discriminant: Some(Discriminant::Int(42)), + docs: &[], + }; + const case3: EnumCase = EnumCase { + name: "Variant3", + discriminant: Some(Discriminant::String("foo")), + docs: &[], + }; + + #[test] + fn test_new_enum_builder() { + let builder = EnumBuilder::new("MyEnum"); + assert_eq!(builder.name, "MyEnum"); + assert!(builder.methods.is_empty()); + assert!(builder.cases.is_empty()); + assert_eq!(builder.datatype, DataType::Undef); + assert!(builder.register.is_none()); + } + + #[test] + fn test_enum_case() { + let builder = EnumBuilder::new("MyEnum").case(&case1); + assert_eq!(builder.cases.len(), 1); + assert_eq!(builder.cases[0].name, "Variant1"); + assert_eq!(builder.datatype, DataType::Undef); + + let builder = EnumBuilder::new("MyEnum").case(&case2); + assert_eq!(builder.cases.len(), 1); + assert_eq!(builder.cases[0].name, "Variant2"); + assert_eq!(builder.cases[0].discriminant, Some(Discriminant::Int(42))); + assert_eq!(builder.datatype, DataType::Long); + + let builder = EnumBuilder::new("MyEnum").case(&case3); + assert_eq!(builder.cases.len(), 1); + assert_eq!(builder.cases[0].name, "Variant3"); + assert_eq!( + builder.cases[0].discriminant, + Some(Discriminant::String("foo")) + ); + assert_eq!(builder.datatype, DataType::String); + } + + #[test] + #[should_panic(expected = "Cannot add case with data type Long to enum with data type Undef")] + fn test_enum_case_mismatch() { + #[allow(unused_must_use)] + EnumBuilder::new("MyEnum").case(&case1).case(&case2); // This should panic because case2 has a different data type + } + + const docs: DocComments = &["This is a test enum"]; + #[test] + fn test_docs() { + let builder = EnumBuilder::new("MyEnum").docs(docs); + assert_eq!(builder.docs.len(), 1); + assert_eq!(builder.docs[0], "This is a test enum"); + } +} diff --git a/src/builders/mod.rs b/src/builders/mod.rs index bb984e86bd..c439c81682 100644 --- a/src/builders/mod.rs +++ b/src/builders/mod.rs @@ -2,6 +2,8 @@ //! Generally zero-cost abstractions. mod class; +#[cfg(feature = "enum")] +mod enum_builder; mod function; #[cfg(all(php82, feature = "embed"))] mod ini; @@ -10,6 +12,8 @@ mod module; mod sapi; pub use class::ClassBuilder; +#[cfg(feature = "enum")] +pub use enum_builder::EnumBuilder; pub use function::FunctionBuilder; #[cfg(all(php82, feature = "embed"))] pub use ini::IniBuilder; diff --git a/src/builders/module.rs b/src/builders/module.rs index 4e5b2d8ee9..929f2cf305 100644 --- a/src/builders/module.rs +++ b/src/builders/module.rs @@ -1,6 +1,8 @@ use std::{convert::TryFrom, ffi::CString, mem, ptr}; use super::{ClassBuilder, FunctionBuilder}; +#[cfg(feature = "enum")] +use crate::{builders::enum_builder::EnumBuilder, enum_::RegisteredEnum}; use crate::{ class::RegisteredClass, constant::IntoConst, @@ -46,6 +48,8 @@ pub struct ModuleBuilder<'a> { pub(crate) functions: Vec>, pub(crate) constants: Vec<(String, Box, DocComments)>, pub(crate) classes: Vec ClassBuilder>, + #[cfg(feature = "enum")] + pub(crate) enums: Vec EnumBuilder>, startup_func: Option, shutdown_func: Option, request_startup_func: Option, @@ -206,6 +210,31 @@ impl ModuleBuilder<'_> { }); self } + + /// Adds an enum to the extension. + #[cfg(feature = "enum")] + pub fn enumeration(mut self) -> Self + where + T: RegisteredClass + RegisteredEnum, + { + self.enums.push(|| { + let mut builder = EnumBuilder::new(T::CLASS_NAME); + for case in T::CASES { + builder = builder.case(case); + } + for (method, flags) in T::method_builders() { + builder = builder.method(method, flags); + } + + builder + .registration(|ce| { + T::get_metadata().set_ce(ce); + }) + .docs(T::DOC_COMMENTS) + }); + + self + } } /// Artifacts from the [`ModuleBuilder`] that should be revisited inside the @@ -213,6 +242,8 @@ impl ModuleBuilder<'_> { pub struct ModuleStartup { constants: Vec<(String, Box)>, classes: Vec ClassBuilder>, + #[cfg(feature = "enum")] + enums: Vec EnumBuilder>, } impl ModuleStartup { @@ -234,6 +265,15 @@ impl ModuleStartup { self.classes.into_iter().map(|c| c()).for_each(|c| { c.register().expect("Failed to build class"); }); + + #[cfg(feature = "enum")] + self.enums + .into_iter() + .map(|builder| builder()) + .for_each(|e| { + e.register().expect("Failed to build enum"); + }); + Ok(()) } } @@ -268,6 +308,8 @@ impl TryFrom> for (ModuleEntry, ModuleStartup) { .map(|(n, v, _)| (n, v)) .collect(), classes: builder.classes, + #[cfg(feature = "enum")] + enums: builder.enums, }; Ok(( @@ -327,6 +369,8 @@ mod tests { assert!(builder.request_shutdown_func.is_none()); assert!(builder.post_deactivate_func.is_none()); assert!(builder.info_func.is_none()); + #[cfg(feature = "enum")] + assert!(builder.enums.is_empty()); } #[test] diff --git a/src/describe/mod.rs b/src/describe/mod.rs index 8abf2592e1..1c98d92ad2 100644 --- a/src/describe/mod.rs +++ b/src/describe/mod.rs @@ -2,6 +2,8 @@ //! CLI application to generate PHP stub files used by IDEs. use std::vec::Vec as StdVec; +#[cfg(feature = "enum")] +use crate::builders::EnumBuilder; use crate::{ builders::{ClassBuilder, FunctionBuilder}, constant::IntoConst, @@ -67,6 +69,9 @@ pub struct Module { pub functions: Vec, /// Classes exported by the extension. pub classes: Vec, + #[cfg(feature = "enum")] + /// Enums exported by the extension. + pub enums: Vec, /// Constants exported by the extension. pub constants: Vec, } @@ -95,6 +100,13 @@ impl From> for Module { .map(Constant::from) .collect::>() .into(), + #[cfg(feature = "enum")] + enums: builder + .enums + .into_iter() + .map(|e| e().into()) + .collect::>() + .into(), } } } @@ -216,6 +228,86 @@ impl From for Class { } } +#[cfg(feature = "enum")] +/// Represents an exported enum. +#[repr(C)] +#[derive(Debug, PartialEq)] +pub struct Enum { + /// Name of the enum. + pub name: RString, + /// Documentation comments for the enum. + pub docs: DocBlock, + /// Cases of the enum. + pub cases: Vec, + /// Backing type of the enum. + pub backing_type: Option, +} + +#[cfg(feature = "enum")] +impl From for Enum { + fn from(val: EnumBuilder) -> Self { + Self { + name: val.name.into(), + docs: DocBlock( + val.docs + .iter() + .map(|d| (*d).into()) + .collect::>() + .into(), + ), + cases: val + .cases + .into_iter() + .map(EnumCase::from) + .collect::>() + .into(), + backing_type: match val.datatype { + DataType::Long => Some("int".into()), + DataType::String => Some("string".into()), + _ => None, + } + .into(), + } + } +} + +#[cfg(feature = "enum")] +/// Represents a case in an exported enum. +#[repr(C)] +#[derive(Debug, PartialEq)] +pub struct EnumCase { + /// Name of the enum case. + pub name: RString, + /// Documentation comments for the enum case. + pub docs: DocBlock, + /// Value of the enum case. + pub value: Option, +} + +#[cfg(feature = "enum")] +impl From<&'static crate::enum_::EnumCase> for EnumCase { + fn from(val: &'static crate::enum_::EnumCase) -> Self { + Self { + name: val.name.into(), + docs: DocBlock( + val.docs + .iter() + .map(|d| (*d).into()) + .collect::>() + .into(), + ), + value: val + .discriminant + .as_ref() + .map(|v| match v { + crate::enum_::Discriminant::Int(i) => i.to_string().into(), + crate::enum_::Discriminant::String(s) => format!("'{s}'").into(), + }) + .into(), + } + } +} + /// Represents a property attached to an exported class. #[repr(C)] #[derive(Debug, PartialEq)] @@ -437,6 +529,8 @@ mod tests { functions: vec![].into(), classes: vec![].into(), constants: vec![].into(), + #[cfg(feature = "enum")] + enums: vec![].into(), }; let description = Description::new(module); diff --git a/src/describe/stub.rs b/src/describe/stub.rs index 58c7428743..50affc3463 100644 --- a/src/describe/stub.rs +++ b/src/describe/stub.rs @@ -1,15 +1,22 @@ //! Traits and implementations to convert describe units into PHP stub code. -use crate::flags::DataType; -use std::{cmp::Ordering, collections::HashMap}; +use std::{ + cmp::Ordering, + collections::HashMap, + fmt::{Error as FmtError, Result as FmtResult, Write}, + option::Option as StdOption, + vec::Vec as StdVec, +}; use super::{ abi::{Option, RString}, Class, Constant, DocBlock, Function, Method, MethodType, Module, Parameter, Property, Visibility, }; -use std::fmt::{Error as FmtError, Result as FmtResult, Write}; -use std::{option::Option as StdOption, vec::Vec as StdVec}; + +#[cfg(feature = "enum")] +use crate::describe::{Enum, EnumCase}; +use crate::flags::DataType; /// Implemented on types which can be converted into PHP stubs. pub trait ToStub { @@ -78,6 +85,12 @@ impl ToStub for Module { insert(ns, class.to_stub()?); } + #[cfg(feature = "enum")] + for r#enum in &*self.enums { + let (ns, _) = split_namespace(r#enum.name.as_ref()); + insert(ns, r#enum.to_stub()?); + } + let mut entries: StdVec<_> = entries.iter().collect(); entries.sort_by(|(l, _), (r, _)| match (l, r) { (None, _) => Ordering::Greater, @@ -241,6 +254,41 @@ impl ToStub for Class { } } +#[cfg(feature = "enum")] +impl ToStub for Enum { + fn fmt_stub(&self, buf: &mut String) -> FmtResult { + self.docs.fmt_stub(buf)?; + + let (_, name) = split_namespace(self.name.as_ref()); + write!(buf, "enum {name}")?; + + if let Option::Some(backing_type) = &self.backing_type { + write!(buf, ": {backing_type}")?; + } + + writeln!(buf, " {{")?; + + for case in self.cases.iter() { + case.fmt_stub(buf)?; + } + + writeln!(buf, "}}") + } +} + +#[cfg(feature = "enum")] +impl ToStub for EnumCase { + fn fmt_stub(&self, buf: &mut String) -> FmtResult { + self.docs.fmt_stub(buf)?; + + write!(buf, " case {}", self.name)?; + if let Option::Some(value) = &self.value { + write!(buf, " = {value}")?; + } + writeln!(buf, ";") + } +} + impl ToStub for Property { fn fmt_stub(&self, buf: &mut String) -> FmtResult { self.docs.fmt_stub(buf)?; diff --git a/src/enum_.rs b/src/enum_.rs new file mode 100644 index 0000000000..0e139f2fa1 --- /dev/null +++ b/src/enum_.rs @@ -0,0 +1,167 @@ +//! This module defines the `PhpEnum` trait and related types for Rust enums that are exported to PHP. +use std::ptr; + +use crate::{ + boxed::ZBox, + class::RegisteredClass, + convert::{FromZendObject, FromZval, IntoZendObject, IntoZval}, + describe::DocComments, + error::{Error, Result}, + ffi::zend_enum_get_case, + flags::{ClassFlags, DataType}, + types::{ZendObject, ZendStr, Zval}, +}; + +/// Implemented on Rust enums which are exported to PHP. +pub trait RegisteredEnum { + /// The cases of the enum. + const CASES: &'static [EnumCase]; + + /// # Errors + /// + /// - [`Error::InvalidProperty`] if the enum does not have a case with the given name, an error is returned. + fn from_name(name: &str) -> Result + where + Self: Sized; + + /// Returns the variant name of the enum as it is registered in PHP. + fn to_name(&self) -> &'static str; +} + +impl FromZendObject<'_> for T +where + T: RegisteredEnum, +{ + fn from_zend_object(obj: &ZendObject) -> Result { + if !ClassFlags::from_bits_truncate(unsafe { (*obj.ce).ce_flags }).contains(ClassFlags::Enum) + { + return Err(Error::InvalidProperty); + } + + let name = obj + .get_properties()? + .get("name") + .and_then(Zval::indirect) + .and_then(Zval::str) + .ok_or(Error::InvalidProperty)?; + + T::from_name(name) + } +} + +impl FromZval<'_> for T +where + T: RegisteredEnum + RegisteredClass, +{ + const TYPE: DataType = DataType::Object(Some(T::CLASS_NAME)); + + fn from_zval(zval: &Zval) -> Option { + zval.object() + .and_then(|obj| Self::from_zend_object(obj).ok()) + } +} + +impl IntoZendObject for T +where + T: RegisteredEnum + RegisteredClass, +{ + fn into_zend_object(self) -> Result> { + let mut name = ZendStr::new(T::to_name(&self), false); + let variant = unsafe { + zend_enum_get_case( + ptr::from_ref(T::get_metadata().ce()).cast_mut(), + &raw mut *name, + ) + }; + + Ok(unsafe { ZBox::from_raw(variant) }) + } +} + +impl IntoZval for T +where + T: RegisteredEnum + RegisteredClass, +{ + const TYPE: DataType = DataType::Object(Some(T::CLASS_NAME)); + const NULLABLE: bool = false; + + fn set_zval(self, zv: &mut Zval, _persistent: bool) -> Result<()> { + let obj = self.into_zend_object()?; + zv.set_object(obj.into_raw()); + Ok(()) + } +} +// impl<'a, T> IntoZval for T +// where +// T: RegisteredEnum + RegisteredClass + IntoZendObject +// { +// const TYPE: DataType = DataType::Object(Some(T::CLASS_NAME)); +// const NULLABLE: bool = false; +// +// fn set_zval(self, zv: &mut Zval, persistent: bool) -> Result<()> { +// let obj = self.into_zend_object()?; +// } +// } + +/// Represents a case in a PHP enum. +pub struct EnumCase { + /// The identifier of the enum case, e.g. `Bar` in `enum Foo { Bar }`. + pub name: &'static str, + /// The value of the enum case, which can be an integer or a string. + pub discriminant: Option, + /// The documentation comments for the enum case. + pub docs: DocComments, +} + +impl EnumCase { + /// Gets the PHP data type of the enum case's discriminant. + #[must_use] + pub fn data_type(&self) -> DataType { + match self.discriminant { + Some(Discriminant::Int(_)) => DataType::Long, + Some(Discriminant::String(_)) => DataType::String, + None => DataType::Undef, + } + } +} + +/// Represents the discriminant of an enum case in PHP, which can be either an integer or a string. +#[derive(Debug, PartialEq, Eq)] +pub enum Discriminant { + /// An integer discriminant. + Int(i64), + /// A string discriminant. + String(&'static str), +} + +impl TryFrom<&Discriminant> for Zval { + type Error = Error; + + fn try_from(value: &Discriminant) -> Result { + match value { + Discriminant::Int(i) => i.into_zval(false), + Discriminant::String(s) => s.into_zval(false), + } + } +} + +#[cfg(test)] +#[cfg(feature = "embed")] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use crate::embed::Embed; + + #[test] + fn test_zval_try_from_discriminant() { + Embed::run(|| { + let zval_int: Zval = Zval::try_from(&Discriminant::Int(42)).unwrap(); + assert!(zval_int.is_long()); + assert_eq!(zval_int.long().unwrap(), 42); + + let zval_str: Zval = Zval::try_from(&Discriminant::String("foo")).unwrap(); + assert!(zval_str.is_string()); + assert_eq!(zval_str.string().unwrap().to_string(), "foo"); + }); + } +} diff --git a/src/flags.rs b/src/flags.rs index f74d2bed6b..179bfc2f8d 100644 --- a/src/flags.rs +++ b/src/flags.rs @@ -2,6 +2,8 @@ use bitflags::bitflags; +#[cfg(php81)] +use crate::ffi::ZEND_ACC_ENUM; #[cfg(not(php82))] use crate::ffi::ZEND_ACC_REUSE_GET_ITERATOR; use crate::ffi::{ @@ -113,6 +115,9 @@ bitflags! { const Trait = ZEND_ACC_TRAIT; /// Anonymous class const AnonymousClass = ZEND_ACC_ANON_CLASS; + /// Class is an Enum + #[cfg(php81)] + const Enum = ZEND_ACC_ENUM; /// Class linked with parent, interfaces and traits const Linked = ZEND_ACC_LINKED; /// Class is abstract, since it is set by any abstract method diff --git a/src/lib.rs b/src/lib.rs index 47e33405af..4b51f64137 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,8 @@ pub mod constant; pub mod describe; #[cfg(feature = "embed")] pub mod embed; +#[cfg(feature = "enum")] +pub mod enum_; #[doc(hidden)] pub mod internal; pub mod props; @@ -47,6 +49,8 @@ pub mod prelude { #[cfg_attr(docs, doc(cfg(feature = "closure")))] pub use crate::closure::Closure; pub use crate::exception::{PhpException, PhpResult}; + #[cfg(feature = "enum")] + pub use crate::php_enum; pub use crate::php_print; pub use crate::php_println; pub use crate::types::ZendCallable; @@ -65,6 +69,8 @@ pub const PHP_DEBUG: bool = cfg!(php_debug); /// Whether the extension is compiled for PHP thread-safe mode. pub const PHP_ZTS: bool = cfg!(php_zts); +#[cfg(feature = "enum")] +pub use ext_php_rs_derive::php_enum; pub use ext_php_rs_derive::{ php_class, php_const, php_extern, php_function, php_impl, php_module, wrap_constant, wrap_function, zend_fastcall, ZvalConvert, diff --git a/src/wrapper.h b/src/wrapper.h index 24326a556a..5ae9098c89 100644 --- a/src/wrapper.h +++ b/src/wrapper.h @@ -19,6 +19,9 @@ #include "ext/standard/info.h" #include "ext/standard/php_var.h" #include "ext/standard/file.h" +#ifdef EXT_PHP_RS_PHP_81 +#include "zend_enum.h" +#endif #include "zend_exceptions.h" #include "zend_inheritance.h" #include "zend_interfaces.h" diff --git a/tests/Cargo.toml b/tests/Cargo.toml index a63fe5a250..845c01acab 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -6,7 +6,12 @@ publish = false license = "MIT OR Apache-2.0" [dependencies] -ext-php-rs = { path = "../", features = ["closure"] } +cfg-if = "1.0.1" +ext-php-rs = { path = "../", default-features = false, features = ["closure"] } + +[features] +default = ["enum"] +enum = ["ext-php-rs/enum"] [lib] crate-type = ["cdylib"] diff --git a/tests/src/integration/enum_/enum.php b/tests/src/integration/enum_/enum.php new file mode 100644 index 0000000000..047b9d1a0c --- /dev/null +++ b/tests/src/integration/enum_/enum.php @@ -0,0 +1,20 @@ +value === 1); +assert(IntBackedEnum::from(2) === IntBackedEnum::Variant2); +assert(IntBackedEnum::tryFrom(1) === IntBackedEnum::Variant1); +assert(IntBackedEnum::tryFrom(3) === null); + +assert(StringBackedEnum::Variant1->value === 'foo'); +assert(StringBackedEnum::from('bar') === StringBackedEnum::Variant2); +assert(StringBackedEnum::tryFrom('foo') === StringBackedEnum::Variant1); +assert(StringBackedEnum::tryFrom('baz') === null); + +assert(test_enum(TestEnum::Variant1) === StringBackedEnum::Variant2); diff --git a/tests/src/integration/enum_/mod.rs b/tests/src/integration/enum_/mod.rs new file mode 100644 index 0000000000..f1e6b95110 --- /dev/null +++ b/tests/src/integration/enum_/mod.rs @@ -0,0 +1,55 @@ +use ext_php_rs::{error::Result, php_enum, php_function, prelude::ModuleBuilder, wrap_function}; + +#[php_enum] +#[php(allow_native_discriminants)] +/// An example enum that demonstrates how to use PHP enums with Rust. +/// This enum has two variants, `Variant1` and `Variant2`. +pub enum TestEnum { + /// Represents the first variant of the enum. + /// This variant has a discriminant of 0. + /// But PHP does not know about it. + Variant1, + /// Represents the second variant of the enum. + Variant2 = 1, +} + +#[php_enum] +pub enum IntBackedEnum { + #[php(value = 1)] + Variant1, + #[php(value = 2)] + Variant2, +} + +#[php_enum] +pub enum StringBackedEnum { + #[php(value = "foo")] + Variant1, + #[php(value = "bar")] + Variant2, +} + +#[php_function] +pub fn test_enum(a: TestEnum) -> Result { + let str: &str = StringBackedEnum::Variant2.into(); + match a { + TestEnum::Variant1 => str.try_into(), + TestEnum::Variant2 => Ok(StringBackedEnum::Variant1), + } +} + +pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { + builder + .enumeration::() + .enumeration::() + .enumeration::() + .function(wrap_function!(test_enum)) +} + +#[cfg(test)] +mod tests { + #[test] + fn enum_works() { + assert!(crate::integration::test::run_php("enum_/enum.php")); + } +} diff --git a/tests/src/integration/mod.rs b/tests/src/integration/mod.rs index 647bbfc5c9..cc18e5108d 100644 --- a/tests/src/integration/mod.rs +++ b/tests/src/integration/mod.rs @@ -5,6 +5,8 @@ pub mod callable; pub mod class; pub mod closure; pub mod defaults; +#[cfg(feature = "enum")] +pub mod enum_; pub mod exception; pub mod globals; pub mod iterator; @@ -28,8 +30,13 @@ mod test { fn setup() { BUILD.call_once(|| { - assert!(Command::new("cargo") - .arg("build") + let mut command = Command::new("cargo"); + command.arg("build").arg("--no-default-features"); + #[cfg(feature = "enum")] + { + command.arg("--features=enum"); + } + assert!(command .output() .expect("failed to build extension") .status diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 0ea8185921..ced895ded5 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -18,6 +18,10 @@ pub fn build_module(module: ModuleBuilder) -> ModuleBuilder { module = integration::class::build_module(module); module = integration::closure::build_module(module); module = integration::defaults::build_module(module); + #[cfg(feature = "enum")] + { + module = integration::enum_::build_module(module); + } module = integration::exception::build_module(module); module = integration::globals::build_module(module); module = integration::iterator::build_module(module); diff --git a/tools/update_lib_docs.sh b/tools/update_lib_docs.sh index d45d0e6181..b8e794dacb 100755 --- a/tools/update_lib_docs.sh +++ b/tools/update_lib_docs.sh @@ -50,6 +50,7 @@ update_docs "function" update_docs "impl" update_docs "module" update_docs "zval_convert" +update_docs "enum" # Format to remove trailing whitespace rustup run nightly rustfmt crates/macros/src/lib.rs