diff --git a/Cargo.toml b/Cargo.toml index 34c44d23..db6f73f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,4 +2,5 @@ resolver = "2" members = [ "promkit", + "promkit-derive", ] diff --git a/promkit-derive/Cargo.toml b/promkit-derive/Cargo.toml new file mode 100644 index 00000000..b3c1008d --- /dev/null +++ b/promkit-derive/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "promkit-derive" +version = "0.1.0" +authors = ["ynqa "] +edition = "2021" +description = "A derive macro for promkit" +repository = "https://github.com/ynqa/promkit" +license = "MIT" +readme = "README.md" + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2.0.52", features = ["full"] } +quote = "1.0" +proc-macro2 = "1.0" +promkit = { path = "../promkit", version = "0.5.1" } diff --git a/promkit-derive/examples/derive.rs b/promkit-derive/examples/derive.rs new file mode 100644 index 00000000..b32e9a9a --- /dev/null +++ b/promkit-derive/examples/derive.rs @@ -0,0 +1,24 @@ +use promkit::{crossterm::style::Color, style::StyleBuilder}; +use promkit_derive::Promkit; + +#[derive(Default, Debug, Promkit)] +struct Profile { + #[form( + label = "What is your name?", + label_style = StyleBuilder::new().fgc(Color::DarkCyan).build(), + )] + name: String, + + #[form(default)] + hobby: Option, + + #[form(label = "How old are you?", ignore_invalid_attr = "nothing")] + age: usize, +} + +fn main() -> Result<(), Box> { + let mut ret = Profile::default(); + ret.build()?; + dbg!(ret); + Ok(()) +} diff --git a/promkit-derive/src/lib.rs b/promkit-derive/src/lib.rs new file mode 100644 index 00000000..56756ca0 --- /dev/null +++ b/promkit-derive/src/lib.rs @@ -0,0 +1,109 @@ +extern crate proc_macro; + +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use syn::{parse::Error, parse_macro_input, spanned::Spanned, DeriveInput}; + +#[proc_macro_derive(Promkit, attributes(form))] +pub fn promkit_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let ast = parse_macro_input!(input as DeriveInput); + match impl_promkit_derive(&ast) { + Ok(token) => token.into(), + Err(e) => e.to_compile_error().into(), + } +} + +mod text_editor; + +fn impl_promkit_derive(ast: &DeriveInput) -> Result { + let fields = match &ast.data { + syn::Data::Struct(s) => match &s.fields { + syn::Fields::Named(fields) => &fields.named, + syn::Fields::Unnamed(_) => { + return Err(Error::new(ast.span(), "Not support tuple structs")) + } + syn::Fields::Unit => return Err(Error::new(ast.span(), "Not support unit structs")), + }, + syn::Data::Enum(_) => return Err(Error::new(ast.span(), "Not support enums")), + syn::Data::Union(_) => return Err(Error::new(ast.span(), "Not support unions")), + }; + + let mut text_editor_states = Vec::new(); + let mut field_assignments = Vec::new(); + let mut field_types = Vec::new(); + + for (idx, field) in fields.iter().enumerate() { + for attr in field.attrs.iter() { + #[allow(clippy::single_match)] + match attr.path().get_ident().unwrap().to_string().as_str() { + "form" => { + let state = text_editor::create_state(attr)?; + text_editor_states.push(state); + + let field_ident = field.ident.as_ref().unwrap(); + let idx_lit = syn::Index::from(idx); + + match &field.ty { + syn::Type::Path(typ) => { + let last_segment = typ.path.segments.last().unwrap(); + match last_segment.ident.to_string().as_str() { + "Option" => { + if let syn::PathArguments::AngleBracketed(args) = + &last_segment.arguments + { + if let Some(syn::GenericArgument::Type(inner_type)) = + args.args.first() + { + field_assignments.push(quote! { + self.#field_ident = results[#idx_lit].parse::<#inner_type>().ok(); + }); + field_types.push(quote! { Option<#inner_type> }); + } + } + } + _ => { + let ty = &field.ty; + field_assignments.push(quote! { + self.#field_ident = results[#idx_lit].parse::<#ty>()?; + }); + field_types.push(quote! { #ty }); + } + } + } + ty => { + return Err(Error::new( + ty.span(), + format!( + "Support only Path for field type but got {}", + ty.to_token_stream(), + ), + )) + } + } + } + _ => (), + } + } + } + + let name = &ast.ident; + let combined_states = quote! { + vec![ + #(#text_editor_states),* + ] + }; + + Ok(quote! { + impl #name { + pub fn build(&mut self) -> Result<(), Box> { + let states = #combined_states; + let mut form = promkit::preset::form::Form::new(states); + let results = form.prompt()?.run()?; + + #(#field_assignments)* + + Ok(()) + } + } + }) +} diff --git a/promkit-derive/src/text_editor.rs b/promkit-derive/src/text_editor.rs new file mode 100644 index 00000000..c20f2a51 --- /dev/null +++ b/promkit-derive/src/text_editor.rs @@ -0,0 +1,98 @@ +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use syn::{parse::Error, punctuated::Punctuated, spanned::Spanned, Meta, MetaNameValue, Token}; + +pub fn create_state(attr: &syn::Attribute) -> Result { + let mut prefix = quote! { String::from("❯❯ ") }; + let mut prefix_style = quote! { + promkit::style::StyleBuilder::new().attrs( + promkit::crossterm::style::Attributes::from( + promkit::crossterm::style::Attribute::Bold, + ) + ).build() + }; + let mut active_char_style = quote! { + promkit::style::StyleBuilder::new().bgc(promkit::crossterm::style::Color::DarkCyan).build() + }; + let mut inactive_char_style = quote! { + promkit::style::StyleBuilder::new().build() + }; + let mut mask = quote! { None:: }; + let mut edit_mode = quote! { promkit::text_editor::Mode::default() }; + let mut word_break_chars = quote! { std::collections::HashSet::from([' ']) }; + + match &attr.meta { + Meta::List(list) => { + if list.tokens.to_string() != "default" { + list.parse_args_with(Punctuated::::parse_terminated) + .map_err(|e| { + Error::new( + list.span(), + format!( + "Support form(key=value, ...) but got {}, caused error: {}", + list.tokens, e + ), + ) + })? + .into_iter() + .for_each( + |entry| match entry.path.get_ident().unwrap().to_string().as_str() { + "label" => { + let expr = entry.value; + prefix = quote! { format!("{} ", #expr) }; + } + "label_style" => { + let expr = entry.value; + prefix_style = quote! { #expr }; + } + "active_char_style" => { + let expr = entry.value; + active_char_style = quote! { #expr }; + } + "inactive_char_style" => { + let expr = entry.value; + inactive_char_style = quote! { #expr }; + } + "mask" => { + let expr = entry.value; + mask = quote! { #expr }; + } + "edit_mode" => { + let expr = entry.value; + edit_mode = quote! { #expr }; + } + "word_break_chars" => { + let expr = entry.value; + word_break_chars = quote! { #expr }; + } + _ => (), + }, + ); + } + } + others => { + return Err(Error::new( + others.span(), + format!( + "Support only form, form(default), or form(key=value, ...), but got {}", + others.to_token_stream() + ), + )) + } + }; + + Ok(quote! { + promkit::text_editor::State { + texteditor: Default::default(), + history: Default::default(), + prefix: #prefix, + prefix_style: #prefix_style, + active_char_style: #active_char_style, + inactive_char_style: #inactive_char_style, + mask: #mask, + edit_mode: #edit_mode, + word_break_chars: #word_break_chars, + lines: Default::default() + } + }) +}