Repository: ynqa/promkit (language: rust)

Issue:
Derive a form (multiple prompts) from struct
I would like to easily annotate a configuration struct, to ask the user for input for various fields and give a hint on validation:

```rust

use derive_bla::AskUser;

#[derive(Debug, AskUser)]
struct MyConf {
    #[ask(q="Please enter password:", hide_input=true, min_len=12)]
    password: Option<String>,

    #[ask(q="Please enter username:", min_len=3)]
    username: Option<String>,

    #[ask(q="Please enter age:", range=18..=85)]
    age: Option<u8>,
}

fn main() -> Result<()> {
    let mut conf = MyConf::default()
    conf.password()?.validate()?;
    conf.username()?.validate()?;
    conf.age()?.validate()?;

    println!("{conf#?}");
}
```

I would be interested if something like this is already existing, e.g. based on inquire, promkit, or if there is any interest to add this?

There are some ideas already in `inquire`, but it feels a bit out of scope.

CC: https://github.com/mikaelmello/inquire/issues/212
CC: https://github.com/mikaelmello/inquire/issues/65

EDIT: It would basically be the `clap` way of doing interactive prompts. Annotate something and give it to the user to let it be filled out. I think it comes with its own advantages and disadvantages, and excels a lot in a workflow where you suddenly want the user to fill out some data you already have bundled in a struct. 

I think it could be a game changing feature, though. Because it would make dealing with interactive prompts much easier for a lot of users. Also, it would be excellent to have for people that don't want the full-fledged control of a `ratatui` TUI, but still a bit more ergonomic when it comes to interactivity and developer experience creating such.


Discussion / hints:
@simonsan Thank you for your proposal!

> I would be interested if something like this is already existing, e.g. based on inquire, promkit, or if there is any interest to add this?

Currently, we do not offer such a feature (this is simply because I am not yet well-versed in Derive/macro). However, setting aside the specifics of the interface, I understand what you are looking to achieve and I am very interested in it.
Nice! In one issue, there is an example/demo of how such a thing could work: https://github.com/IniterWorker/inquire_derive

Maybe it helps to get started. Also, there is the [The Little Book of Rust Macros](https://veykril.github.io/tlborm/proc-macros/methodical/derive.html) and there is the [proc-macro workshop by dtolnay](https://github.com/dtolnay/proc-macro-workshop). I'm saying this, because I'm trying to support and hope that such a thing will exist at one point in time, as I think it would be really valuable to the ecosystem. (:
@simonsan I've started by creating a prototype. I believe it needs to be refined further, but what do you think?
- https://github.com/ynqa/promkit/blob/dev-0.4.0/promkit-derive/promkit-derive/examples/example.rs

```rust
use promkit::{crossterm::style::Color, style::StyleBuilder, Result};
use promkit_derive::Promkit;

#[derive(Default, Debug, Promkit)]
struct MyStruct {
    #[ask(
        prefix = "What is your name?",
        prefix_style = StyleBuilder::new().fgc(Color::DarkCyan).build(),
    )]
    name: String,

    #[ask(prefix = "How old are you?", ignore_invalid_attr = "nothing")]
    age: usize,
}

fn main() -> Result {
    let ret = MyStruct::default().ask_name()?.ask_age()?;
    dbg!(ret);
    Ok(())
}
```
Looks really nice! 👍🏽 I need to try it out, to see if the API is ergonomic for me. But the way you instantiate and do it, is exactly how I would imagine it. In the end, it's like a builder pattern, that is being exposed directly to the user. For example, if you would like to ask for each field in the struct it would be pleasant to have a short calling method like `MyStruct::ask()`.

Which would internally call `Default::default()` on all fields, if `#[ask(default)]` is set on this field.

- returning a result, so we can return early with the try operator after each step

- `validate` could be implemented on top of it at later stage, there is a [`validator`](https://crates.io/crates/validator) crate, which API is ergonomic and could be inspiring. `T.validate_args((77, 555)).is_ok()` sound like a good pattern to me as well

- for attributes to annotate [`TypedBuilder`](https://idanarye.github.io/rust-typed-builder/typed_builder/derive.TypedBuilder.html) is interesting to look at for inspiration I think

Great work! 🌷 
@simonsan Thank you for your comment! 👍 Please feel free to continue posting your feedback after you have had a chance to try it out. As a favor, could I ask for the opportunity to have you take another review the macro after I've refined it based on your feedback?
> @simonsan Thank you for your comment! 👍 Please feel free to continue post your feedback after you have had a chance to try it out. As a favor, could I ask for the opportunity to have you take another look at the macro after I've refined it based on your feedback?

For sure! :) 👍🏽 

Fix patch:
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 <un.pensiero.vano@gmail.com>"]
+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<String>,
+
+    #[form(label = "How old are you?", ignore_invalid_attr = "nothing")]
+    age: usize,
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    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<TokenStream, Error> {
+    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<dyn std::error::Error>> {
+                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<TokenStream, Error> {
+    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::<char> };
+    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::<MetaNameValue, Token![,]>::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()
+        }
+    })
+}


Test patch: