diff --git a/rust/fory-derive/src/lib.rs b/rust/fory-derive/src/lib.rs index 0a7b8e7663..5801d7b8c1 100644 --- a/rust/fory-derive/src/lib.rs +++ b/rust/fory-derive/src/lib.rs @@ -145,6 +145,10 @@ //! `map`, or `tuple` metadata to select serializers recursively at child nodes. //! - **`#[fory(default)]`**: Marks the fallible deserialization default `ForyUnion` variant. //! `ForyUnion` requires exactly one default variant. +//! - **`#[fory(native)]`**: Selects the Rust-native `enum` representation for a `ForyUnion` +//! enum even when every variant is unit or carries only one value. The enum declares no +//! `#[fory(unknown)]` carrier, uses normal registration, and is rejected in xlang mode. +//! Valid only on `ForyUnion`. //! //! ## Field Types //! @@ -239,7 +243,11 @@ pub fn proc_macro_derive_fory_struct(input: proc_macro::TokenStream) -> TokenStr .into_compile_error() .into(); } - derive_serializer(input) + let attrs = match parse_fory_attrs(&input.attrs, false) { + Ok(attrs) => attrs, + Err(err) => return err.into_compile_error().into(), + }; + derive_serializer(input, attrs) } /// Derive macro for pure enum serialization. @@ -263,28 +271,33 @@ pub fn proc_macro_derive_fory_enum(input: proc_macro::TokenStream) -> TokenStrea .into_compile_error() .into(); } - derive_serializer(input) + let attrs = match parse_fory_attrs(&input.attrs, false) { + Ok(attrs) => attrs, + Err(err) => return err.into_compile_error().into(), + }; + derive_serializer(input, attrs) } /// Derives serialization for data-carrying Rust enums. /// /// Xlang-compatible unit and single-payload variants use the Fory `UNION` /// representation. Native multi-field tuple and named variants use the native -/// `ENUM` representation. +/// `ENUM` representation. `#[fory(native)]` selects the native `ENUM` +/// representation for any variant shape. #[proc_macro_derive(ForyUnion, attributes(fory))] pub fn proc_macro_derive_fory_union(input: proc_macro::TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - if let Err(err) = object::derive_union::validate_input(&input) { + let attrs = match parse_fory_attrs(&input.attrs, true) { + Ok(attrs) => attrs, + Err(err) => return err.into_compile_error().into(), + }; + if let Err(err) = object::derive_union::validate_input(&input, attrs.native) { return err.into_compile_error().into(); } - derive_serializer(input) + derive_serializer(input, attrs) } -fn derive_serializer(input: DeriveInput) -> TokenStream { - let attrs = match parse_fory_attrs(&input.attrs) { - Ok(attrs) => attrs, - Err(err) => return err.into_compile_error().into(), - }; +fn derive_serializer(input: DeriveInput, attrs: ForyAttrs) -> TokenStream { let runtime_root = match runtime_root::resolve_runtime_root() { Ok(root) => root, Err(err) => return err.into_compile_error().into(), @@ -340,14 +353,17 @@ pub(crate) struct ForyAttrs { pub generate_default: bool, pub evolving: Option, pub target: Option, + pub native: bool, } -/// Parse fory attributes and return ForyAttrs -fn parse_fory_attrs(attrs: &[Attribute]) -> syn::Result { +/// Parse fory attributes and return ForyAttrs. `native` is accepted only when +/// `allow_native` is set by the `ForyUnion` derive. +fn parse_fory_attrs(attrs: &[Attribute], allow_native: bool) -> syn::Result { let mut debug_flag: Option = None; let mut generate_default_flag: Option = None; let mut evolving_flag: Option = None; let mut target: Option = None; + let mut native_flag: Option = None; for attr in attrs { if attr.path().is_ident("fory") { @@ -411,6 +427,26 @@ fn parse_fory_attrs(attrs: &[Attribute]) -> syn::Result { )); } target = Some(meta.value()?.parse()?); + } else if meta.path.is_ident("native") { + if !allow_native { + return Err(meta.error("`native` is valid only for ForyUnion")); + } + let value = if meta.input.is_empty() { + true + } else { + let lit: LitBool = meta.value()?.parse()?; + lit.value + }; + native_flag = match native_flag { + Some(existing) if existing != value => { + return Err(syn::Error::new( + meta.path.span(), + "conflicting `native` attribute values", + )); + } + Some(_) => native_flag, + None => Some(value), + }; } else { return Err(meta.error("unsupported type-level fory attribute")); } @@ -433,6 +469,7 @@ fn parse_fory_attrs(attrs: &[Attribute]) -> syn::Result { generate_default: generate_default_flag.unwrap_or(false), evolving: evolving_flag, target, + native: native_flag.unwrap_or(false), }) } @@ -450,7 +487,7 @@ mod tests { name: String, } }; - let attrs = parse_fory_attrs(&input.attrs).unwrap(); + let attrs = parse_fory_attrs(&input.attrs, false).unwrap(); assert_eq!( attrs.target.unwrap().to_token_stream().to_string(), "external :: User" @@ -465,7 +502,7 @@ mod tests { name: String, } }; - let err = match parse_fory_attrs(&input.attrs) { + let err = match parse_fory_attrs(&input.attrs, false) { Ok(_) => panic!("external structural serializers must reject `generate_default`"), Err(err) => err, }; diff --git a/rust/fory-derive/src/object/derive_enum.rs b/rust/fory-derive/src/object/derive_enum.rs index 27dc725089..0f90c04b8a 100644 --- a/rust/fory-derive/src/object/derive_enum.rs +++ b/rust/fory-derive/src/object/derive_enum.rs @@ -25,6 +25,10 @@ use crate::util::{extract_fields, source_fields, SourceField}; use proc_macro2::{Ident, TokenStream}; use quote::quote; use syn::{DataEnum, Fields}; + +const NATIVE_ENUM_XLANG_ERROR: &str = + "#[fory(native)] and multi-field enum variants are Rust-native and not representable in xlang mode"; + fn temp_var_name(i: usize) -> String { format!("f{}", i) } @@ -231,16 +235,15 @@ fn gen_read_single_payload(source_fields: &[SourceField<'_>]) -> TokenStream { } } -/// For Union-compatible enums with data variants, return UNION TypeId in xlang mode. -pub fn gen_actual_type_id(data_enum: &DataEnum) -> TokenStream { - let is_union_compatible = is_union_compatible_enum(data_enum); +/// For xlang unions, return UNION TypeId in xlang mode. +pub fn gen_actual_type_id(data_enum: &DataEnum, xlang_union: bool) -> TokenStream { let has_data_variants = data_enum .variants .iter() .any(|v| !matches!(v.fields, Fields::Unit)); - if is_union_compatible && has_data_variants { - // Union-compatible enum: use typed/named union IDs in xlang mode + if xlang_union { + // Xlang union: use typed/named union IDs in xlang mode quote! { Ok(if xlang { if register_by_name { @@ -255,9 +258,7 @@ pub fn gen_actual_type_id(data_enum: &DataEnum) -> TokenStream { } else if has_data_variants { quote! { if xlang { - Err(fory_core::Error::not_allowed( - "multi-field tuple and named enum variants are not representable in xlang mode", - )) + Err(fory_core::Error::not_allowed(#NATIVE_ENUM_XLANG_ERROR)) } else { Ok(fory_core::serializer::enum_::actual_type_id( type_id, @@ -695,6 +696,7 @@ fn compatible_variant_writes( pub fn gen_write_data( data_enum: &DataEnum, + xlang_union: bool, generics: &syn::Generics, target_path: &TokenStream, ) -> TokenStream { @@ -702,7 +704,7 @@ pub fn gen_write_data( .variants .iter() .any(|variant| !matches!(variant.fields, Fields::Unit)) - && !is_union_compatible_enum(data_enum); + && !xlang_union; let default_variant_value = data_enum .variants .iter() @@ -717,9 +719,7 @@ pub fn gen_write_data( compatible_variant_writes(data_enum, generics, target_path, default_variant_value); let xlang_write = if native_only { quote! { - Err(fory_core::Error::not_allowed( - "multi-field tuple and named enum variants are not representable in xlang mode", - )) + Err(fory_core::Error::not_allowed(#NATIVE_ENUM_XLANG_ERROR)) } } else { quote! { @@ -749,15 +749,9 @@ pub fn gen_write_data( } } -pub fn gen_write_type_info(data_enum: &DataEnum) -> TokenStream { - let is_union_compatible = is_union_compatible_enum(data_enum); - let has_data_variants = data_enum - .variants - .iter() - .any(|v| !matches!(v.fields, Fields::Unit)); - - if is_union_compatible && has_data_variants { - // Union-compatible with data: write typed/named union type info in xlang mode +pub fn gen_write_type_info(xlang_union: bool) -> TokenStream { + if xlang_union { + // Xlang union: write typed/named union type info in xlang mode quote! { if context.is_xlang() { let provider_type_id = ::std::any::TypeId::of::(); @@ -792,7 +786,7 @@ pub fn gen_read_with_type_info(_: &DataEnum) -> TokenStream { /// Check if enum is Union-compatible: /// - Must have at least one data-carrying variant (single-field) /// - All variants must be either unit or single-field -fn is_union_compatible_enum(data_enum: &DataEnum) -> bool { +pub(crate) fn is_union_compatible_enum(data_enum: &DataEnum) -> bool { let has_data_variant = data_enum .variants .iter() @@ -819,16 +813,10 @@ fn xlang_union_case_id(data_enum: &DataEnum, idx: usize, variant: &syn::Variant) } /// Generate the static TypeId for enum. -/// For Union-compatible enums with data variants, return UNION TypeId -/// to ensure correct type info handling in xlang mode struct field read/write. -pub fn gen_static_type_id(data_enum: &DataEnum) -> TokenStream { - let is_union_compatible = is_union_compatible_enum(data_enum); - let has_data_variants = data_enum - .variants - .iter() - .any(|v| !matches!(v.fields, Fields::Unit)); - - if is_union_compatible && has_data_variants { +/// For xlang unions, return UNION TypeId to ensure correct type info handling +/// in xlang mode struct field read/write. +pub fn gen_static_type_id(xlang_union: bool) -> TokenStream { + if xlang_union { quote! { fory_core::TypeId::UNION } } else { quote! { fory_core::TypeId::ENUM } @@ -1096,10 +1084,10 @@ fn compatible_variant_reads( pub fn gen_read_data( data_enum: &DataEnum, + xlang_union: bool, generics: &syn::Generics, target_path: &TokenStream, ) -> TokenStream { - let is_union_compatible = is_union_compatible_enum(data_enum); let has_data_variants = data_enum .variants .iter() @@ -1127,7 +1115,7 @@ pub fn gen_read_data( let default_variant_construction = variant_default_value(default_variant, target_path); - let unknown_xlang_branch = if is_union_compatible && has_data_variants { + let unknown_xlang_branch = if xlang_union { // ForyUnion validation guarantees xlang-compatible ADTs have the // runtime unknown carrier. A skip/default fallback here would drop // forward-compatible payload metadata instead of preserving it. @@ -1158,11 +1146,9 @@ pub fn gen_read_data( } } }; - let xlang_read = if has_data_variants && !is_union_compatible { + let xlang_read = if has_data_variants && !xlang_union { quote! { - Err(fory_core::Error::not_allowed( - "multi-field tuple and named enum variants are not representable in xlang mode", - )) + Err(fory_core::Error::not_allowed(#NATIVE_ENUM_XLANG_ERROR)) } } else { quote! { diff --git a/rust/fory-derive/src/object/derive_union.rs b/rust/fory-derive/src/object/derive_union.rs index 9723998603..c5ea51f480 100644 --- a/rust/fory-derive/src/object/derive_union.rs +++ b/rust/fory-derive/src/object/derive_union.rs @@ -18,7 +18,7 @@ use super::util::{enum_variant_id, has_fory_unknown_attr, is_runtime_unknown_variant}; use syn::{Attribute, Data, DataEnum, DeriveInput, Fields}; -pub(crate) fn validate_input(input: &DeriveInput) -> syn::Result<()> { +pub(crate) fn validate_input(input: &DeriveInput, native: bool) -> syn::Result<()> { let Data::Enum(data_enum) = &input.data else { return Err(syn::Error::new( input.ident.span(), @@ -35,6 +35,14 @@ pub(crate) fn validate_input(input: &DeriveInput) -> syn::Result<()> { "ForyUnion requires at least one payload variant; use ForyEnum for pure unit enums", )); } + if native { + if let Some(variant) = data_enum.variants.iter().find(|v| has_fory_unknown_attr(v)) { + return Err(syn::Error::new( + variant.ident.span(), + "#[fory(native)] ForyUnion enums cannot declare #[fory(unknown)]; unknown-case carriers belong to xlang unions", + )); + } + } if let Some(variant) = data_enum.variants.iter().find(|variant| { has_fory_unknown_attr(variant) && (!is_runtime_unknown_variant(variant) || enum_variant_id(variant).is_some()) @@ -56,7 +64,10 @@ pub(crate) fn validate_input(input: &DeriveInput) -> syn::Result<()> { "ForyUnion must declare exactly one #[fory(unknown)] carrier", )); } - if is_typed_adt_union(data_enum) && !data_enum.variants.iter().any(is_runtime_unknown_variant) { + if !native + && is_typed_adt_union(data_enum) + && !data_enum.variants.iter().any(is_runtime_unknown_variant) + { return Err(syn::Error::new( input.ident.span(), "ForyUnion typed ADT unions require a #[fory(unknown)] carrier with one UnknownCase field", @@ -223,7 +234,7 @@ mod tests { } ); - let error = validate_input(&input).unwrap_err(); + let error = validate_input(&input, false).unwrap_err(); assert!(error .to_string() .contains("unknown case must have #[fory(unknown)]")); @@ -241,7 +252,7 @@ mod tests { Text(String), } ); - let error = validate_input(&input).unwrap_err(); + let error = validate_input(&input, false).unwrap_err(); assert!(error .to_string() .contains("exactly one #[fory(unknown)] carrier")); @@ -256,12 +267,42 @@ mod tests { } ); - let error = validate_input(&input).unwrap_err(); + let error = validate_input(&input, false).unwrap_err(); assert!(error .to_string() .contains("ForyUnion typed ADT unions require")); } + #[test] + fn native_union_needs_no_carrier() { + let input: DeriveInput = parse_quote!( + enum NativeUnion { + #[fory(default)] + Empty, + Dog(String), + } + ); + + validate_input(&input, true).unwrap(); + } + + #[test] + fn native_union_rejects_carrier() { + let input: DeriveInput = parse_quote!( + enum NativeUnion { + #[fory(unknown)] + Unknown(::fory::UnknownCase), + #[fory(default)] + Dog(String), + } + ); + + let error = validate_input(&input, true).unwrap_err(); + assert!(error + .to_string() + .contains("cannot declare #[fory(unknown)]")); + } + #[test] fn mixed_unit_payload_union_requires_unknown_carrier() { let input: DeriveInput = parse_quote!( @@ -272,7 +313,7 @@ mod tests { } ); - let error = validate_input(&input).unwrap_err(); + let error = validate_input(&input, false).unwrap_err(); assert!(error .to_string() .contains("ForyUnion typed ADT unions require")); @@ -291,7 +332,7 @@ mod tests { } ); - let error = validate_input(&input).unwrap_err(); + let error = validate_input(&input, false).unwrap_err(); assert!(error.to_string().contains("duplicate ForyUnion case id 0")); } @@ -308,7 +349,7 @@ mod tests { } ); - let error = validate_input(&input).unwrap_err(); + let error = validate_input(&input, false).unwrap_err(); assert!(error.to_string().contains("duplicate ForyUnion case id 0")); } } diff --git a/rust/fory-derive/src/object/serializer.rs b/rust/fory-derive/src/object/serializer.rs index a01c024e42..d8b7cfc065 100644 --- a/rust/fory-derive/src/object/serializer.rs +++ b/rust/fory-derive/src/object/serializer.rs @@ -141,30 +141,33 @@ pub fn derive_serializer( read_data_always_advances, ) } - Data::Enum(data) => ( - derive_enum::gen_actual_type_id(data), - quote! { &[] }, - derive_enum::gen_field_fields_info(data), - derive_enum::gen_variants_fields_info(name, &ast.generics, data), - quote! { - let _ = (context, type_info); - Err(fory_core::Error::not_allowed( - "read_compatible is valid only for struct targets", - )) - }, - derive_enum::gen_variant_meta_types(name, &ast.generics, data), - derive_enum::gen_write(data), - derive_enum::gen_write_data(data, &ast.generics, &target_path), - derive_enum::gen_write_type_info(data), - derive_enum::gen_read(data), - derive_enum::gen_read_with_type_info(data), - derive_enum::gen_read_data(data, &ast.generics, &target_path), - derive_enum::gen_read_type_info(data), - derive_enum::gen_default_value(data, &target_path), - derive_enum::gen_reserved_space(), - derive_enum::gen_static_type_id(data), - quote! { true }, - ), + Data::Enum(data) => { + let xlang_union = !attrs.native && derive_enum::is_union_compatible_enum(data); + ( + derive_enum::gen_actual_type_id(data, xlang_union), + quote! { &[] }, + derive_enum::gen_field_fields_info(data), + derive_enum::gen_variants_fields_info(name, &ast.generics, data), + quote! { + let _ = (context, type_info); + Err(fory_core::Error::not_allowed( + "read_compatible is valid only for struct targets", + )) + }, + derive_enum::gen_variant_meta_types(name, &ast.generics, data), + derive_enum::gen_write(data), + derive_enum::gen_write_data(data, xlang_union, &ast.generics, &target_path), + derive_enum::gen_write_type_info(xlang_union), + derive_enum::gen_read(data), + derive_enum::gen_read_with_type_info(data), + derive_enum::gen_read_data(data, xlang_union, &ast.generics, &target_path), + derive_enum::gen_read_type_info(data), + derive_enum::gen_default_value(data, &target_path), + derive_enum::gen_reserved_space(), + derive_enum::gen_static_type_id(xlang_union), + quote! { true }, + ) + } Data::Union(_) => { clear_struct_context(); return syn::Error::new_spanned(name, "Rust unions are not supported") diff --git a/rust/tests/tests/test_enum.rs b/rust/tests/tests/test_enum.rs index 2f97774e6d..1df9cb89d8 100644 --- a/rust/tests/tests/test_enum.rs +++ b/rust/tests/tests/test_enum.rs @@ -129,6 +129,45 @@ fn named_enum() { assert_eq!(value1, value2); } +/// Test [issue 4177](https://github.com/apache/fory/issues/4177). +#[test] +fn native_union() { + use fory_core::serializer::Serializer; + use fory_core::type_id::TypeId; + + // Without `native`, this unit/single-payload shape selects the xlang UNION + // category and requires an unknown-case carrier. + #[derive(ForyUnion, Debug, PartialEq)] + #[fory(native)] + enum Event { + #[fory(default)] + Empty, + Text(String), + Code { + value: i32, + }, + } + + assert_eq!(Event::static_type_id(), TypeId::ENUM); + + for compatible in [false, true] { + let mut fory = Fory::builder().xlang(false).compatible(compatible).build(); + assert!(fory.register_union::(1001).is_err()); // register_union only accepts TypeId::UNION. + fory.register::(1001).unwrap(); + for event in [ + Event::Empty, + Event::Text("text".to_string()), + Event::Code { value: 3 }, + ] { + let bytes = fory.serialize(&event).unwrap(); + assert_eq!(fory.deserialize::(&bytes).unwrap(), event); + } + } + + let mut fory = Fory::builder().xlang(true).build(); + assert!(fory.register::(1001).is_err()); +} + /// Test that struct with enum field serializes correctly. #[test] fn struct_with_enum_field() {