diff --git a/Cargo.toml b/Cargo.toml index b75d4fb..6656cdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ workspace = { members = ["libshvproto-macros"] } [package] name = "shvproto" -version = "3.0.11" +version = "3.0.16" edition = "2021" [dependencies] diff --git a/libshvproto-macros/src/lib.rs b/libshvproto-macros/src/lib.rs index af95750..c123b47 100644 --- a/libshvproto-macros/src/lib.rs +++ b/libshvproto-macros/src/lib.rs @@ -1,8 +1,11 @@ use convert_case::{Case, Casing}; +use syn::punctuated::Punctuated; +use syn::{Meta, Token}; use core::panic; use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; use quote::quote; fn is_option(ty: &syn::Type) -> bool { @@ -39,7 +42,84 @@ fn get_type(ty: &syn::Type) -> Option { } } -#[proc_macro_derive(TryFromRpcValue, attributes(field_name))] +fn get_field_name(field: &syn::Field) -> String { + field + .attrs.first() + .and_then(|attr| attr.meta.require_name_value().ok()) + .filter(|meta_name_value| meta_name_value.path.is_ident("field_name")) + .map(|meta_name_value| if let syn::Expr::Lit(expr) = &meta_name_value.value { expr } else { panic!("Expected a string literal for 'field_name'") }) + .map(|literal| if let syn::Lit::Str(expr) = &literal.lit { expr.value() } else { panic!("Expected a string literal for 'field_name'") }) + .unwrap_or_else(|| field.ident.as_ref().unwrap().to_string().to_case(Case::Camel)) +} + +fn field_to_initializers( + field_name: &str, + identifier: &syn::Ident, + is_option: bool, + from_value: Option, + context: &str, +) -> (TokenStream2, TokenStream2) +{ + let struct_initializer; + let rpcvalue_insert; + let identifier_at_value = if let Some(value) = from_value { + quote! { #value.#identifier } + } else { + quote! { #identifier } + }; + if is_option { + struct_initializer = quote!{ + #identifier: match get_key(#field_name).ok() { + Some(x) => Some(x.try_into().map_err(|e| format!("{}Cannot parse `{}` field: {e}", #context, #field_name))?), + None => None, + }, + }; + rpcvalue_insert = quote!{ + if let Some(val) = #identifier_at_value { + map.insert(#field_name.into(), val.into()); + } + }; + } else { + struct_initializer = quote!{ + #identifier: get_key(#field_name) + .and_then(|x| x.try_into().map_err(|e| format!("Cannot parse `{}` field: {e}", #field_name))) + .map_err(|e| format!("{}{e}", #context))?, + }; + rpcvalue_insert = quote!{ + map.insert(#field_name.into(), #identifier_at_value.into()); + }; + } + (struct_initializer, rpcvalue_insert) +} + +#[derive(Default)] +struct StructAttributes { + tag: Option, +} + +fn parse_struct_attributes(attrs: &Vec) -> syn::Result { + let mut res = StructAttributes::default(); + for attr in attrs { + if attr.path().is_ident("rpcvalue") { + let nested = attr.parse_args_with(Punctuated::::parse_terminated)?; + for meta in &nested { + match meta { + // #[rpcvalue(tag = "type")] + Meta::NameValue(name_value) if name_value.path.is_ident("tag") => { + let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(tag_lit), .. }) = &name_value.value else { + return Err(syn::Error::new_spanned(meta, "tag value is not a string literal")); + }; + res.tag = Some(tag_lit.value()); + } + _ => return Err(syn::Error::new_spanned(meta, "unrecognized attributes")), + } + } + } + } + Ok(res) +} + +#[proc_macro_derive(TryFromRpcValue, attributes(field_name,rpcvalue))] pub fn derive_from_rpcvalue(item: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(item as syn::DeriveInput); let struct_identifier = &input.ident; @@ -59,32 +139,16 @@ pub fn derive_from_rpcvalue(item: TokenStream) -> TokenStream { let mut expected_keys = vec![]; let mut rpcvalue_inserts = quote!{}; for field in fields { - let identifier = field.ident.as_ref().unwrap(); - let field_name = field - .attrs.first() - .and_then(|attr| attr.meta.require_name_value().ok()) - .filter(|meta_name_value| meta_name_value.path.is_ident("field_name")) - .map(|meta_name_value| if let syn::Expr::Lit(expr) = &meta_name_value.value { expr } else { panic!("Expected a string literal for 'field_name'") }) - .map(|literal| if let syn::Lit::Str(expr) = &literal.lit { expr.value() } else { panic!("Expected a string literal for 'field_name'") }) - .unwrap_or_else(|| identifier.to_string().to_case(Case::Camel)); - - if is_option(&field.ty) { - struct_initializers.extend(quote!{ - #identifier: get_key(#field_name).ok().and_then(|x| x.try_into().ok()), - }); - rpcvalue_inserts.extend(quote!{ - if let Some(val) = value.#identifier { - map.insert(#field_name.into(), val.into()); - } - }); - } else { - struct_initializers.extend(quote!{ - #identifier: get_key(#field_name).and_then(|x| x.try_into())?, - }); - rpcvalue_inserts.extend(quote!{ - map.insert(#field_name.into(), value.#identifier.into()); - }); - } + let field_name = get_field_name(field); + let (struct_initializer, rpcvalue_insert) = field_to_initializers( + &field_name, + field.ident.as_ref().expect("Missing field identifier"), + is_option(&field.ty), + Some(quote! { value }), + "", + ); + struct_initializers.extend(struct_initializer); + rpcvalue_inserts.extend(rpcvalue_insert); expected_keys.push(quote!{#field_name}); } @@ -121,7 +185,7 @@ pub fn derive_from_rpcvalue(item: TokenStream) -> TokenStream { impl #struct_generics_with_bounds TryFrom<&shvproto::Map> for #struct_identifier #struct_generics_without_bounds { type Error = String; fn try_from(value: &shvproto::Map) -> Result>::Error> { - let get_key = |key_name| value.get(key_name).ok_or_else(|| "Missing ".to_string() + key_name + " key"); + let get_key = |key_name| value.get(key_name).ok_or_else(|| "Missing `".to_string() + key_name + "` key"); let unexpected_keys = value .keys() .map(String::as_str) @@ -158,6 +222,8 @@ pub fn derive_from_rpcvalue(item: TokenStream) -> TokenStream { let mut match_arms_ser = quote!{}; let mut allowed_types = vec![]; let mut custom_type_matchers = vec![]; + let mut match_arms_tags = vec![]; + let struct_attributes = parse_struct_attributes(&input.attrs).unwrap(); let mut map_has_been_matched_as_map: Option<(proc_macro2::TokenStream, proc_macro2::TokenStream)> = None; let mut map_has_been_matched_as_struct: Option> = None; for variant in variants { @@ -203,7 +269,10 @@ pub fn derive_from_rpcvalue(item: TokenStream) -> TokenStream { .iter() .map(|(ident, ty)| quote!{ #ident(#ty) }) .collect::>(); - panic!("Can't match enum variant {}(Map), because a Map will already be matched as one of: {}", quote!{#variant_ident}, quote!{#(#matched_variants),*}); + panic!("Can't match enum variant {}(Map), because a Map will already be matched as one of: {}", + quote!{#variant_ident}, + quote!{#(#matched_variants),*} + ); } add_type_matcher(&mut match_arms_de, quote!{Map(x)}, quote!{Map}, unbox_code.clone()); map_has_been_matched_as_map = Some((quote!(#variant_ident), quote!{#source_variant_type})); @@ -211,7 +280,12 @@ pub fn derive_from_rpcvalue(item: TokenStream) -> TokenStream { "IMap" => add_type_matcher(&mut match_arms_de, quote!{IMap(x)}, quote!{IMap}, unbox_code.clone()), _ => { if let Some((matched_variant_ident, matched_variant_type)) = map_has_been_matched_as_map { - panic!("Can't match enum variant {}({}) as a Map, because a Map will already be matched as {}({})", quote!{#variant_ident}, quote!{#source_variant_type}, quote!{#matched_variant_ident}, quote!{#matched_variant_type}); + panic!("Can't match enum variant {}({}) as a Map, because a Map will already be matched as {}({})", + quote!{#variant_ident}, + quote!{#source_variant_type}, + quote!{#matched_variant_ident}, + quote!{#matched_variant_type} + ); } custom_type_matchers.push(quote!{ if let Ok(val) = #source_variant_type::try_from(x.as_ref().clone()) { @@ -232,15 +306,108 @@ pub fn derive_from_rpcvalue(item: TokenStream) -> TokenStream { }); add_type_matcher(&mut match_arms_de, quote!{String(s) if s.as_str() == stringify!(#variant_ident)}, quote!{#variant_ident}, quote!()); }, - syn::Fields::Named(_) => () + syn::Fields::Named(variant_fields) => { + if let Some((matched_variant_ident, matched_variant_type)) = map_has_been_matched_as_map { + panic!("Can't match enum variant {}(...) as a Map, because a Map will already be matched as {}({})", + quote!{#variant_ident}, + quote!{#matched_variant_ident}, + quote!{#matched_variant_type} + ); + } + + let mut struct_initializers = quote! {}; + let mut rpcvalue_inserts = quote! {}; + let mut field_idents = vec![]; + let variant_ident_name = variant_ident.to_string().to_case(Case::Camel); + + for field in &variant_fields.named { + let field_ident = field.ident.as_ref().expect("Missing field identifier"); + let (struct_initializer, rpcvalue_insert) = field_to_initializers( + get_field_name(field).as_str(), + field_ident, + is_option(&field.ty), + None, + &format!("Cannot deserialize into `{}` enum variant: ", variant_ident_name.as_str()), + ); + struct_initializers.extend(struct_initializer); + rpcvalue_inserts.extend(rpcvalue_insert); + field_idents.push(field_ident); + } + + if let Some(tag_key) = &struct_attributes.tag { + rpcvalue_inserts.extend(quote! { + map.insert(#tag_key.into(), #variant_ident_name.into()); + }); + } else { + rpcvalue_inserts = quote! { + map.insert(#variant_ident_name.into(), { + let mut map = shvproto::rpcvalue::Map::new(); + #rpcvalue_inserts + map.into() + }); + }; + } + + match_arms_ser.extend(quote!{ + #struct_identifier::#variant_ident{ #(#field_idents),* } => { + let mut map = shvproto::rpcvalue::Map::new(); + #rpcvalue_inserts + map.into() + } + }); + + match_arms_tags.push(quote! { + #variant_ident_name => Ok(#struct_identifier::#variant_ident { + #struct_initializers + }), + }); + + map_has_been_matched_as_struct + .get_or_insert(vec![]) + .push((quote!(#variant_ident), quote!(#(#field_idents)*))); + }, } } + if !match_arms_tags.is_empty() { + if let Some(tag_key) = &struct_attributes.tag { + custom_type_matchers.push(quote! { + let tag: String = x.get(#tag_key) + .ok_or_else(|| "Missing `".to_string() + #tag_key + "` key") + .and_then(|val| val + .try_into() + .map_err(|e: String| "Cannot parse `".to_string() + #tag_key + "` field: " + &e) + ) + .map_err(|e| "Cannot get tag: ".to_string() + &e)?; + }); + } else { + custom_type_matchers.push(quote! { + let (tag, val) = x.iter().nth(0).ok_or_else(|| "Cannot get tag from an empty Map")?; + let x: shvproto::rpcvalue::Map = val.try_into()?; + }); + } + + custom_type_matchers.push(quote! { + let get_key = |key_name| x + .get(key_name) + .ok_or_else(|| "Missing `".to_string() + key_name + "` key"); + + match tag.as_str() { + #(#match_arms_tags)* + _ => Err("Couldn't deserialize into `".to_owned() + stringify!(#struct_identifier) + "` enum from a Map. Unknown tag `" + &tag + "`"), + } + }); + } + if !custom_type_matchers.is_empty() { allowed_types.push(quote!{stringify!(Map(x))}); - custom_type_matchers.push(quote!{ - Err("Couldn't deserialize into '".to_owned() + stringify!(#struct_identifier) + "' enum from a Map.") - }); + if match_arms_tags.is_empty() { + // The match in match_arms_tags is the last expression returned from + // custom_type_matchers. If match_arms_tags is empty, just return Err. + custom_type_matchers.push(quote!{ + Err("Couldn't deserialize into `".to_owned() + stringify!(#struct_identifier) + "` enum from a Map.") + }); + } match_arms_de.push(quote!{ shvproto::Value::Map(x) => { #(#custom_type_matchers)* @@ -254,7 +421,7 @@ pub fn derive_from_rpcvalue(item: TokenStream) -> TokenStream { fn try_from(value: shvproto::RpcValue) -> Result>::Error> { match value.value() { #(#match_arms_de),*, - _ => Err("Couldn't deserialize into '".to_owned() + stringify!(#struct_identifier) + "' enum, allowed types: " + [#(#allowed_types),*].join("|").as_ref() + ", got: " + value.type_name()) + _ => Err("Couldn't deserialize into `".to_owned() + stringify!(#struct_identifier) + "` enum, allowed types: " + [#(#allowed_types),*].join("|").as_ref() + ", got: " + value.type_name()) } } } diff --git a/src/rpcvalue.rs b/src/rpcvalue.rs index 45a6187..10b5729 100644 --- a/src/rpcvalue.rs +++ b/src/rpcvalue.rs @@ -239,6 +239,15 @@ impl From> for RpcValue { } } +impl From> for RpcValue +where + RpcValue: From, +{ + fn from(value: Option) -> Self { + value.map_or_else(RpcValue::null, RpcValue::from) + } +} + #[cfg(feature = "specialization")] mod with_specialization { use super::{ @@ -648,6 +657,23 @@ impl TryFrom for chrono::NaiveDateTime { } } +impl TryFrom<&Value> for chrono::DateTime { + type Error = String; + fn try_from(value: &Value) -> Result { + match value { + Value::DateTime(val) => Ok(val.to_chrono_datetime()), + _ => Err(format_err_try_from("DateTime", value.type_name())) + } + } +} + +impl TryFrom for chrono::DateTime { + type Error = String; + fn try_from(value: Value) -> Result { + Self::try_from(&value) + } +} + // Cannot use generic trait implementation here. // See `rustc --explain E0210` macro_rules! try_from_rpc_value_ref { @@ -674,6 +700,7 @@ macro_rules! try_from_rpc_value { } try_from_rpc_value_ref!(()); +try_from_rpc_value!(()); try_from_rpc_value_ref!(bool); try_from_rpc_value!(bool); try_from_rpc_value_ref!(&'a str); @@ -713,7 +740,8 @@ try_from_rpc_value_ref!(datetime::DateTime); try_from_rpc_value!(datetime::DateTime); try_from_rpc_value_ref!(chrono::NaiveDateTime); try_from_rpc_value!(chrono::NaiveDateTime); - +try_from_rpc_value_ref!(chrono::DateTime); +try_from_rpc_value!(chrono::DateTime); impl TryFrom<&RpcValue> for Vec where diff --git a/tests/test.rs b/tests/test.rs index 3bb5b67..c8832ee 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -129,6 +129,14 @@ mod test { }); } + #[test] + #[should_panic] + fn optional_field_failing() { + let _x: OptionalFieldStruct = shvproto::make_map!( + "optionalIntField" => "bar" + ).try_into().expect("Failed to parse"); + } + fn test_case(v: T) where T: TryFrom + Into + std::fmt::Debug + Clone + PartialEq, @@ -228,4 +236,121 @@ mod test { let rv = shvproto::RpcValue::from("foo"); let _v: UnitVariantsOnlyEnum = rv.try_into().unwrap(); } + + #[derive(Clone,Debug,PartialEq,TryFromRpcValue)] + pub enum EnumWithNamedFields { + Linux { shell: String, user: String, uptime_days: i32, }, + MacOsX { shell: String, user: String, uptime_days: i32, }, + Windows { user: Option, number_of_failures: i64 }, + } + + #[test] + fn enum_with_named_fields() { + test_case(EnumWithNamedFields::Linux { user: "alice".to_string(), shell: "bash".to_string(), uptime_days: 888 }); + test_case(EnumWithNamedFields::MacOsX { shell: "zsh".to_string(), user: "bob".to_string(), uptime_days: 666, }); + test_case(EnumWithNamedFields::Windows { user: Some("boomer".to_string()), number_of_failures: 12 << 33 }); + } + + #[test] + #[should_panic] + fn enum_with_named_fields_invalid_type_failing() { + let rv = shvproto::RpcValue::from("foo"); + let _v: EnumWithNamedFields = rv.try_into().unwrap(); + } + + #[test] + #[should_panic] + fn enum_with_named_fields_missing_tag_failing() { + let rv: shvproto::RpcValue = shvproto::make_map!{ + "user" => "alice", + "shell" => "csh", + "uptimeDays" => 1, + }.into(); + let _v: EnumWithNamedFields = rv.try_into().unwrap(); + } + + #[test] + #[should_panic] + fn enum_with_named_fields_missing_field_failing() { + let rv: shvproto::RpcValue = shvproto::make_map!{ + "type" => "macOsX", + "user" => "alice", + "uptimeDays" => 1, + }.into(); + let _v: EnumWithNamedFields = rv.try_into().unwrap(); + } + + #[test] + #[should_panic] + fn enum_with_named_fields_field_type_mismatch_failing() { + let rv: shvproto::RpcValue = shvproto::make_map!{ + "type" => "macOsX", + "user" => "alice", + "shell" => vec!["bash", "sh"], + "uptimeDays" => 1, + }.into(); + let _v: EnumWithNamedFields = rv.try_into().unwrap(); + } + + #[test] + fn enum_with_named_fields_tryinto() { + let rv: shvproto::RpcValue = shvproto::make_map!{ + "linux" => shvproto::make_map!{ + "user" => "alice", + "shell" => "bash", + "uptimeDays" => 10, + }, + }.into(); + let _v: EnumWithNamedFields = rv.try_into().unwrap(); + } + + #[derive(Clone,Debug,PartialEq,TryFromRpcValue)] + #[rpcvalue(tag = "os")] + pub enum EnumWithNamedFieldsCustomTag { + Linux { shell: String, user: String, uptime_days: i32, }, + MacOsX { shell: String, user: String, uptime_days: i32, }, + Windows { user: Option, number_of_failures: i64 }, + } + + #[test] + fn enum_with_named_fields_custom_tag() { + test_case(EnumWithNamedFieldsCustomTag::Linux { user: "alice".to_string(), shell: "bash".to_string(), uptime_days: 888 }); + test_case(EnumWithNamedFieldsCustomTag::MacOsX { shell: "zsh".to_string(), user: "bob".to_string(), uptime_days: 666, }); + test_case(EnumWithNamedFieldsCustomTag::Windows { user: Some("boomer".to_string()), number_of_failures: 12 << 33 }); + } + + #[test] + fn enum_with_named_fields_custom_tag_tryinto() { + let rv: shvproto::RpcValue = shvproto::make_map!{ + "os" => "linux", + "user" => "alice", + "shell" => "bash", + "uptimeDays" => 10, + }.into(); + let _v: EnumWithNamedFieldsCustomTag = rv.try_into().unwrap(); + } + + #[test] + #[should_panic] + fn enum_with_named_fields_custom_tag_type_mismatch() { + let rv: shvproto::RpcValue = shvproto::make_map!{ + "os" => 0, + "user" => "alice", + "shell" => "bash", + "uptimeDays" => 10, + }.into(); + let _v: EnumWithNamedFieldsCustomTag = rv.try_into().unwrap(); + } + + #[test] + #[should_panic] + fn enum_with_named_fields_custom_tag_missing() { + let rv: shvproto::RpcValue = shvproto::make_map!{ + "type" => "linux", + "user" => "alice", + "shell" => "bash", + "uptimeDays" => 10, + }.into(); + let _v: EnumWithNamedFieldsCustomTag = rv.try_into().unwrap(); + } }