diff --git a/Cargo.lock b/Cargo.lock index e050724..76b84cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,6 +351,7 @@ name = "charming" version = "0.4.0" dependencies = [ "assert-json-diff", + "charming-derive", "deno_core", "handlebars", "image", @@ -364,6 +365,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "charming-derive" +version = "0.1.0" +dependencies = [ + "charming", + "proc-macro2", + "quote", + "serde", + "syn", +] + [[package]] name = "charming-gallery" version = "0.1.0" @@ -1589,9 +1601,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.63" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b368fba921b0dce7e60f5e04ec15e565b3303972b42bcfde1d0713b881959eb" +checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" dependencies = [ "unicode-ident", ] @@ -1607,9 +1619,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.29" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "573015e8ab27661678357f27dc26460738fd2b6c86e46f386fde94cb5d913105" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -2115,9 +2127,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.32" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "239814284fd6f1a4ffe4ca893952cdd93c224b6a1571c9a9eadd670295c0c9e2" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", diff --git a/charming-derive/Cargo.toml b/charming-derive/Cargo.toml new file mode 100644 index 0000000..351f7b1 --- /dev/null +++ b/charming-derive/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "charming-derive" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0.87" +quote = "1.0.37" +syn = { version = "2.0.79", features = ["extra-traits", "visit", "derive"] } + +[dev-dependencies] +charming = { path = "../charming" } +serde = { version = "1.0", features = ["derive"] } diff --git a/charming-derive/src/lib.rs b/charming-derive/src/lib.rs new file mode 100644 index 0000000..c0d7df5 --- /dev/null +++ b/charming-derive/src/lib.rs @@ -0,0 +1,236 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] +/*! +CharmingSetter is a derive macro for the Charming chart rendering library. It is used to remove a lot of boilerplate code +in the library by implementing common methods to set the fields of structs. + +Example without the macro +```rust +use serde::Serialize; +use charming::component::Title; +use charming::element::Tooltip; + +#[derive(Serialize, Debug, Clone)] +struct Chart { + #[serde(skip_serializing_if = "Vec::is_empty")] + title: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + tooltip: Option<Tooltip>, + + // many fields cut for brevity +} + +// For setting these fields with the setter pattern we would need to implement the following +// methods manually when we are not using the derive macro +impl Chart { + pub fn new() -> Self { + Self { + title: Vec::new(), + tooltip: None, + } + } + pub fn title<T: Into<Title>>(mut self, title: T) -> Self { + self.title.push(title.into()); + self + } + pub fn tooltip<T: Into<Tooltip>>(mut self, tooltip: T) -> Self { + self.tooltip = Some(tooltip.into()); + self + } + + // many methods cut for brevity +} + +// Example chart creation +let chart = Chart::new().title(Title::new().text("Title")).tooltip(Tooltip::new()); +``` + +Example with the macro +```rust +use serde::Serialize; +use charming::component::Title; +use charming::element::Tooltip; +use charming_derive::CharmingSetter; + +#[derive(Serialize, Debug, Clone, CharmingSetter)] +struct Chart { + #[serde(skip_serializing_if = "Vec::is_empty")] + title: Vec<Title>, + #[serde(skip_serializing_if = "Option::is_none")] + tooltip: Option<Tooltip>, + + // many fields cut for brevity +} +// The setter methods from the example above now get implemented automatically by CharmingSetter + +// Example chart creation +let chart = Chart::new().title(Title::new().text("Title")).tooltip(Tooltip::new()); +``` + +Field attributes charming_type and charming_skip_setter +```rust +use serde::Serialize; +use charming::datatype::DataPoint; +use charming_derive::CharmingSetter; + +#[derive(Serialize, Debug, Clone, CharmingSetter)] +#[serde(rename_all = "camelCase")] +pub struct Line { + // charming_type gets used here to provide the value "line" as the default value for + // the field 'type_' when calling Line::new() and also removes the method to set the field + #[serde(rename = "type")] + #[charming_type = "line"] + type_: String, + + // cut for brevity + + // charming_skip_setter gets used here to remove the default implementation of the setter + // method for this field, a manual method to set this field needs to be provided instead + #[serde(skip_serializing_if = "Vec::is_empty")] + #[charming_skip_setter] + data: Vec<DataPoint>, +} + +impl Line { + pub fn data<D: Into<DataPoint>>(mut self, data: Vec<D>) -> Self { + self.data = data.into_iter().map(|d| d.into()).collect(); + self + } +} + +``` +*/ + +use syn::visit::Visit; + +#[proc_macro_derive(CharmingSetter, attributes(charming_skip_setter, charming_type))] +pub fn charming_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input = syn::parse_macro_input!(input as syn::DeriveInput); + + let ident = input.ident; + let mut field_list = vec![]; + let mut method_list = vec![]; + + let fields = match &input.data { + syn::Data::Struct(ref data) => match data.fields { + syn::Fields::Named(ref fields) => fields.named.iter().collect::<Vec<_>>(), + _ => unimplemented!( + "Can't implement CharmingSetter on a unit struct or a struct with unnamed fields" + ), + }, + syn::Data::Enum(_data_enum) => unimplemented!("Can't implement CharmingSetter on an Enum"), + syn::Data::Union(_data_union) => { + unimplemented!("Can't implement CharmingSetter on a Union") + } + }; + + 'outer: for field in fields { + let field_ident = field.ident.as_ref().unwrap(); + let field_types = gather_all_type_reprs(&field.ty); + let type_ident = + syn::Ident::new(field_types.last().unwrap(), proc_macro2::Span::call_site()); + let shorthand_char = field_types.last().unwrap().chars().next().unwrap(); + let shorthand = syn::Ident::new( + &shorthand_char.to_string().to_uppercase(), + proc_macro2::Span::call_site(), + ); + + for attribute in &field.attrs { + match &attribute.meta { + syn::Meta::Path(path) => { + if let Some(segment) = path.segments.last() { + if let "charming_skip_setter" = segment.ident.to_string().as_str() { + if field_types[0].starts_with("Option") { + field_list.push(quote::quote! { #field_ident: None, }); + } else if field_types[0].starts_with("Vec") { + field_list + .push(quote::quote! { #field_ident: std::vec::Vec::new(), }); + } + continue 'outer; + } + } + } + syn::Meta::List(_meta_list) => {} + syn::Meta::NameValue(meta_name_value) => { + if let Some(segment) = meta_name_value.path.segments.last() { + if segment.ident.to_string().starts_with("charming_type") { + match &meta_name_value.value { + syn::Expr::Lit(expr_lit) => { + if let syn::Lit::Str(string_lit) = &expr_lit.lit { + let value = string_lit.value(); + field_list.push( + quote::quote! { #field_ident: #value.to_string(), }, + ); + } else { + unimplemented!("Expected string literal for charming_type") + } + continue 'outer; + } + _ => { + unimplemented!("Can't implement non Literals for charming_type") + } + } + } + } + } + } + } + if field_types[0].starts_with("Option") { + field_list.push(quote::quote! { #field_ident: None, }); + method_list.push(quote::quote! { + pub fn #field_ident<#shorthand: Into<#type_ident>>(mut self, #field_ident: #shorthand) -> Self { + self.#field_ident = Some(#field_ident.into()); + self + } + }); + } else if field_types[0].starts_with("Vec") { + field_list.push(quote::quote! { #field_ident: std::vec::Vec::new(), }); + method_list.push(quote::quote! { + pub fn #field_ident<#shorthand: Into<#type_ident>>(mut self, #field_ident: #shorthand) -> Self { + self.#field_ident.push(#field_ident.into()); + self + } + }); + } + } + + quote::quote!( + impl #ident { + pub fn new() -> Self { + Self { + #(#field_list)* + } + } + #(#method_list)* + } + ) + .into() +} + +// Following code is from enso_macro_utils but supports syn2.0 + +#[derive(Default)] +struct TypeGatherer<'ast> { + /// Observed types accumulator. + pub types: Vec<&'ast syn::TypePath>, +} + +impl<'ast> syn::visit::Visit<'ast> for TypeGatherer<'ast> { + fn visit_type_path(&mut self, node: &'ast syn::TypePath) { + self.types.push(node); + syn::visit::visit_type_path(self, node); + } +} + +fn gather_all_types(node: &syn::Type) -> Vec<&syn::TypePath> { + let mut type_gatherer = TypeGatherer::default(); + type_gatherer.visit_type(node); + type_gatherer.types +} + +fn gather_all_type_reprs(node: &syn::Type) -> Vec<String> { + gather_all_types(node).iter().map(repr).collect() +} + +fn repr<T: quote::ToTokens>(t: &T) -> String { + quote::quote!(#t).to_string() +} diff --git a/charming-derive/tests/derive_test.rs b/charming-derive/tests/derive_test.rs new file mode 100644 index 0000000..30c754e --- /dev/null +++ b/charming-derive/tests/derive_test.rs @@ -0,0 +1,36 @@ +use charming_derive::CharmingSetter; +use serde::Serialize; + +#[derive(Serialize, Debug, Clone, CharmingSetter)] +#[serde(rename_all = "camelCase")] +struct LineComponent { + #[serde(rename = "type")] + #[charming_type = "line"] + type_: String, + + #[serde(skip_serializing_if = "Option::is_none")] + name: Option<String>, + + #[serde(skip_serializing_if = "Vec::is_empty")] + title: Vec<String>, + + #[serde(skip_serializing_if = "Vec::is_empty")] + #[charming_skip_setter] + data: Vec<i32>, +} + +impl LineComponent { + pub fn data<I: Into<i32>>(mut self, data: Vec<I>) -> Self { + self.data = data.into_iter().map(|d| d.into()).collect(); + self + } +} + +#[test] +fn expand_test() { + LineComponent::new() + .name("LineComponent") + .title("Title 1") + .title("Title 2") + .data(vec![1, 2, 3]); +} diff --git a/charming/Cargo.toml b/charming/Cargo.toml index 42423d3..e6d10f4 100644 --- a/charming/Cargo.toml +++ b/charming/Cargo.toml @@ -17,11 +17,12 @@ handlebars = { version = "4.3", optional = true } image = { version = "0.24", optional = true } resvg = { version = "0.36", optional = true } serde = { version = "1.0", features = ["derive"] } -serde-wasm-bindgen = {version = "0.6", optional = true } +serde-wasm-bindgen = { version = "0.6", optional = true } serde_json = "1.0" serde_v8 = { version = "0.220", optional = true } serde_with = "3.11.0" wasm-bindgen = { version = "0.2", optional = true } +charming-derive = { path = "../charming-derive" } [dev-dependencies] assert-json-diff = "2.0.2" @@ -29,11 +30,7 @@ assert-json-diff = "2.0.2" [dependencies.web-sys] version = "0.3.64" optional = true -features = [ - "Window", - "Document", - "Element", -] +features = ["Window", "Document", "Element"] [features] default = ["html"]