Repository: zhiburt/tabled
Pull request #398 (2024-03-25 22:32:32)

Tabled attribute format
Hello,

This is for #396 .

I hope it's fine, it was probably a bit too difficult for me as a first issue. I tried to do something very similar to display_with but I encounter some difficulties with properties and nested structs/enums. I would be happy to see what corrections you can make to improve it.

By the way, thanks a lot for the library! :)

FYI, I needed that for this project : https://github.com/thomassimmer/NeoPass

Cheers

zhiburt: And yes, don't worry about merge changes.
zhiburt: Am I wrong that with your changes `display_with` will also support arguments like `self.field1`.
I bet I am.

And I bet it's good.

Just need to document and create tests. 
thomassimmer: > Am I wrong that with your changes `display_with` will also support arguments like `self.field1`. I bet I am not.
> 
> And I bet it's good.
> 
> Just need to document and create tests.

You are right! I added one test, let me know if it's fine.
zhiburt: Hey @thomassimmer 

I'll merge it,
will add a few more things,
and release hopefully soon.

But could you let me know your thoughts on this `tuple` thing?

Patch:
diff --git a/README.md b/README.md
index 4dbc281..9735538 100644
--- a/README.md
+++ b/README.md
@@ -1592,6 +1592,36 @@ impl MyRecord {
 }
 ```
 
+To reduce boilerplate code, one can also achieve this using the `format` attribute within `#[derive(Tabled)]`.
+
+```rust
+use tabled::Tabled;
+
+#[derive(Tabled)]
+pub struct Motorcycle {
+    weight: usize,
+    #[tabled(format = "{} cc")]
+    cc: usize,
+}
+```
+
+In the above example, the cc field will be formatted using the specified format string "{} cc", where {} is replaced with the value of cc.
+
+Just like with `display_with` attribute, you can pass arguments for more complex formatting scenarios:
+
+```rust
+use tabled::Tabled;
+
+#[derive(Tabled)]
+pub struct Motorcycle {
+    weight: usize,
+    #[tabled(format = "{}/{} cc/kg", self.cc, self.weight)]
+    cc: usize,
+}
+```
+
+In this case, the cc field will be formatted using the format string "{}/{} cc/kg", and {} will be replaced with the values of cc and weight, respectively.
+
 ### Inline
 
 It's possible to inline internal data if it implements the `Tabled` trait using `#[tabled(inline)]`.
diff --git a/tabled_derive/src/attributes.rs b/tabled_derive/src/attributes.rs
index 0e415ea..3f79621 100644
--- a/tabled_derive/src/attributes.rs
+++ b/tabled_derive/src/attributes.rs
@@ -19,6 +19,8 @@ pub struct FieldAttributes {
     pub display_with: Option<String>,
     pub display_with_args: Option<Vec<FuncArg>>,
     pub order: Option<usize>,
+    pub format: Option<String>,
+    pub format_with_args: Option<Vec<FuncArg>>,
 }
 
 impl FieldAttributes {
@@ -70,6 +72,16 @@ impl FieldAttributes {
                     self.display_with_args = Some(args);
                 }
             }
+            FieldAttrKind::FormatWith(format, comma, args) => {
+                self.format = Some(format.value());
+                if comma.is_some() {
+                    let args = args
+                        .into_iter()
+                        .map(|lit| parse_func_arg(&lit))
+                        .collect::<Result<Vec<_>, _>>()?;
+                    self.format_with_args = Some(args);
+                }
+            }
             FieldAttrKind::Order(value) => self.order = Some(lit_int_to_usize(&value)?),
         }
 
@@ -143,6 +155,7 @@ fn lit_int_to_usize(value: &LitInt) -> Result<usize, Error> {
 #[derive(Debug)]
 pub enum FuncArg {
     SelfRef,
+    SelfProperty(String),
     Byte(u8),
     Char(char),
     Bool(bool),
@@ -181,6 +194,10 @@ fn parse_func_arg(expr: &syn::Expr) -> syn::Result<FuncArg> {
                 Err(syn::Error::new(path.span(), "unsuported argument"))
             }
         }
+        syn::Expr::Field(field) => match &field.member {
+            syn::Member::Named(ident) => Ok(FuncArg::SelfProperty(ident.to_string())),
+            syn::Member::Unnamed(index) => Ok(FuncArg::SelfProperty(index.index.to_string())),
+        },
         expr => Err(syn::Error::new(expr.span(), "unsuported argument")),
     }
 }
diff --git a/tabled_derive/src/lib.rs b/tabled_derive/src/lib.rs
index 58f2525..0b29225 100644
--- a/tabled_derive/src/lib.rs
+++ b/tabled_derive/src/lib.rs
@@ -183,7 +183,7 @@ fn info_from_fields(
 ) -> Result<Impl, Error> {
     let count_fields = fields.len();
 
-    let fields = fields
+    let attributes = fields
         .into_iter()
         .enumerate()
         .map(|(i, field)| -> Result<_, Error> {
@@ -198,7 +198,7 @@ fn info_from_fields(
     let mut reorder = HashMap::new();
 
     let mut skipped = 0;
-    for result in fields {
+    for result in attributes {
         let (i, field, attributes) = result?;
         if attributes.is_ignored {
             skipped += 1;
@@ -218,8 +218,8 @@ fn info_from_fields(
         let header = field_headers(field, i, &attributes, header_prefix, trait_path);
         headers.push(header);
 
-        let field_name = field_name(i, field);
-        let value = get_field_fields(&field_name, &attributes);
+        let field_name_result = field_name(i, field);
+        let value = get_field_fields(&field_name_result, &attributes, fields, &field_name);
         values.push(value);
     }
 
@@ -404,7 +404,10 @@ fn info_from_variant(
             Some(args) => match args.is_empty() {
                 true => None,
                 false => {
-                    let args = args.iter().map(fnarg_tokens).collect::<Vec<_>>();
+                    let args = args
+                        .iter()
+                        .map(|arg| fnarg_tokens(arg, &Fields::Unit, field_var_name))
+                        .collect::<Vec<_>>();
                     Some(quote!( #(#args,)* ))
                 }
             },
@@ -416,6 +419,27 @@ fn info_from_variant(
         };
 
         quote! { ::std::borrow::Cow::from(format!("{}", #result)) }
+    } else if let Some(custom_format) = &attr.format {
+        let args = match &attr.format_with_args {
+            None => None,
+            Some(args) => match args.is_empty() {
+                true => None,
+                false => {
+                    let args = args
+                        .iter()
+                        .map(|arg| fnarg_tokens(arg, &Fields::Unit, field_var_name))
+                        .collect::<Vec<_>>();
+                    Some(quote!( #(#args,)* ))
+                }
+            },
+        };
+
+        let call = match args {
+            Some(args) => quote!(format!(#custom_format, #args)),
+            None => quote!(format!(#custom_format)),
+        };
+
+        quote! { ::std::borrow::Cow::from(#call) }
     } else {
         let default_value = "+";
         quote! { ::std::borrow::Cow::Borrowed(#default_value) }
@@ -454,7 +478,12 @@ fn get_type_headers(
     }
 }
 
-fn get_field_fields(field: &TokenStream, attr: &FieldAttributes) -> TokenStream {
+fn get_field_fields(
+    field: &TokenStream,
+    attr: &FieldAttributes,
+    fields: &Fields,
+    field_name: impl Fn(usize, &Field) -> TokenStream,
+) -> TokenStream {
     if attr.inline {
         return quote! { #field.fields() };
     }
@@ -465,7 +494,10 @@ fn get_field_fields(field: &TokenStream, attr: &FieldAttributes) -> TokenStream
             Some(args) => match args.is_empty() {
                 true => None,
                 false => {
-                    let args = args.iter().map(fnarg_tokens).collect::<Vec<_>>();
+                    let args = args
+                        .iter()
+                        .map(|arg| fnarg_tokens(arg, fields, &field_name))
+                        .collect::<Vec<_>>();
                     Some(quote!( #(#args,)* ))
                 }
             },
@@ -477,6 +509,27 @@ fn get_field_fields(field: &TokenStream, attr: &FieldAttributes) -> TokenStream
         };
 
         return quote!(vec![::std::borrow::Cow::from(format!("{}", #result))]);
+    } else if let Some(custom_format) = &attr.format {
+        let args = match &attr.format_with_args {
+            None => None,
+            Some(args) => match args.is_empty() {
+                true => None,
+                false => {
+                    let args = args
+                        .iter()
+                        .map(|arg| fnarg_tokens(arg, fields, &field_name))
+                        .collect::<Vec<_>>();
+                    Some(quote!( #(#args,)* ))
+                }
+            },
+        };
+
+        let call = match args {
+            Some(args) => use_format(&args, custom_format),
+            None => use_format_no_args(custom_format, field),
+        };
+
+        return quote!(vec![::std::borrow::Cow::Owned(#call)]);
     }
 
     quote!(vec![::std::borrow::Cow::Owned(format!("{}", #field))])
@@ -508,6 +561,14 @@ fn use_function_no_args(function: &str) -> TokenStream {
     }
 }
 
+fn use_format(args: &TokenStream, custom_format: &str) -> TokenStream {
+    quote! { format!(#custom_format, #args) }
+}
+
+fn use_format_no_args(custom_format: &str, field: &TokenStream) -> TokenStream {
+    quote! { format!(#custom_format, #field) }
+}
+
 fn field_var_name(index: usize, field: &Field) -> TokenStream {
     let f = field.ident.as_ref().map_or_else(
         || Index::from(index).to_token_stream(),
@@ -650,9 +711,27 @@ fn merge_attributes(attr: &mut FieldAttributes, global_attr: &TypeAttributes) {
     }
 }
 
-fn fnarg_tokens(arg: &FuncArg) -> TokenStream {
+fn fnarg_tokens(
+    arg: &FuncArg,
+    fields: &Fields,
+    field_name: impl Fn(usize, &Field) -> TokenStream,
+) -> TokenStream {
     match arg {
         FuncArg::SelfRef => quote! { &self },
+        FuncArg::SelfProperty(val) => {
+            let property_name = syn::Ident::new(val, proc_macro2::Span::call_site());
+
+            // We find the corresponding field in the local object fields instead of using self,
+            // which would be a higher level object. This is for nested structures.
+            for (i, field) in fields.iter().enumerate() {
+                let field_name_result = field_name(i, field);
+                if field_name_result.to_string() == *val {
+                    return quote! { #field_name_result };
+                }
+            }
+
+            quote! { &self.#property_name }
+        }
         FuncArg::Byte(val) => quote! { #val },
         FuncArg::Char(val) => quote! { #val },
         FuncArg::Bool(val) => quote! { #val },
diff --git a/tabled_derive/src/parse/field_attr.rs b/tabled_derive/src/parse/field_attr.rs
index 396b5bb..761a333 100644
--- a/tabled_derive/src/parse/field_attr.rs
+++ b/tabled_derive/src/parse/field_attr.rs
@@ -33,6 +33,7 @@ pub enum FieldAttrKind {
     RenameAll(LitStr),
     DisplayWith(LitStr, Option<Token!(,)>, Punctuated<syn::Expr, Token!(,)>),
     Order(LitInt),
+    FormatWith(LitStr, Option<Token!(,)>, Punctuated<syn::Expr, Token!(,)>),
 }
 
 impl Parse for FieldAttr {
@@ -54,6 +55,9 @@ impl Parse for FieldAttr {
                     "display_with" => {
                         return Ok(Self::new(name, DisplayWith(lit, None, Punctuated::new())))
                     }
+                    "format" => {
+                        return Ok(Self::new(name, FormatWith(lit, None, Punctuated::new())))
+                    }
                     _ => {}
                 }
             }
@@ -90,7 +94,7 @@ impl Parse for FieldAttr {
                 let lit = nested.parse::<LitStr>()?;
 
                 match name_str.as_str() {
-                    "display_with" => {
+                    "format" | "display_with" => {
                         let mut args = Punctuated::new();
                         let mut comma = None;
                         if nested.peek(Token![,]) {
@@ -106,6 +110,10 @@ impl Parse for FieldAttr {
                             }
                         };
 
+                        if name_str.as_str() == "format" {
+                            return Ok(Self::new(name, FormatWith(lit, comma, args)));
+                        }
+
                         return Ok(Self::new(name, DisplayWith(lit, comma, args)));
                     }
                     "inline" => {


Test patch:
diff --git a/tabled/tests/derive/derive_test.rs b/tabled/tests/derive/derive_test.rs
index c8dfe53..78406a3 100644
--- a/tabled/tests/derive/derive_test.rs
+++ b/tabled/tests/derive/derive_test.rs
@@ -226,6 +226,28 @@ mod tuple {
         }
     );
 
+    test_tuple!(
+        format_1,
+        t: { u8 #[tabled(format = "foo {}")] sstr },
+        init: { 0 "v2" },
+        expected: ["0", "1"], ["0", "foo v2"],
+    );
+
+    test_tuple!(
+        format_2,
+        t: { u8 #[tabled(format = "foo {:?}")] sstr },
+        init: { 0 "v2" },
+        expected: ["0", "1"], ["0", "foo \"v2\""],
+    );
+
+    // todo : self represents the tuple here. It should be the sstr element instead.
+    // test_tuple!(
+    //     format_3,
+    //     t: { u8 #[tabled(format("foo {} {}", 2, self))] sstr },
+    //     init: { 0 "v2" },
+    //     expected: ["0", "1"], ["0", "foo 2 v2"],
+    // );
+
     // #[test]
     // fn order_compile_fail_when_order_is_bigger_then_count_fields() {
     //     #[derive(Tabled)]
@@ -661,6 +683,30 @@ mod enum_ {
     test_enum!(order_15, t: { #[tabled(order = 2)] V1(u8) #[tabled(order = 2)] V2(u8) #[tabled(order = 2)] V3(u8) }, headers: ["V1", "V2", "V3"], tests: V1(0) => ["+", "", ""], V2(0) => ["", "+", ""], V3(0) => ["", "", "+"],);
 
     test_enum!(order_0_inlined, t: #[tabled(inline)] { #[tabled(order = 1)] V1(u8) V2(u8) V3(u8) }, headers: ["TestType"], tests: V1(0) => ["V1"], V2(0) => ["V2"], V3(0) => ["V3"],);
+
+    test_enum!(
+        format,
+        t: {
+            #[tabled(inline)]
+            AbsdEgh {
+                #[tabled(format("{}-{}", self.a, self.b))]
+                a: u8,
+                b: i32
+            }
+            #[tabled(format("{} s", 4))]
+            B(String)
+            #[tabled(inline)]
+            C(#[tabled(rename = "C", format("{} ss", 4))] String)
+            #[tabled(format = "k.")]
+            K
+        },
+        headers: ["a", "b", "B", "C", "K"],
+        tests:
+            AbsdEgh { a: 0, b: 1 }  => ["0-1", "1", "", "", ""],
+            B(String::new()) => ["",  "", "4 s", "", ""],
+            C(String::new()) => ["", "",  "", "4 ss", ""],
+            K => ["", "", "",  "", "k."],
+    );
 }
 
 mod unit {
@@ -751,6 +797,21 @@ mod structure {
         init: { f1: 0, f2: Some("v2") }
         expected: ["f1", "f2"], ["0", "1 2 3"]
     );
+    test_struct!(
+        display_with_args_using_self,
+        t: {
+            f1: u8,
+            #[tabled(display_with("display_option", self.f1, 2, 3))]
+            f2: Option<sstr>,
+        }
+        pre: {
+            fn display_option(v1: &u8, v2: usize, v3: usize) -> String {
+                format!("{v1} {v2} {v3}")
+            }
+        }
+        init: { f1: 0, f2: Some("v2") }
+        expected: ["f1", "f2"], ["0", "0 2 3"]
+    );
     test_struct!(
         display_with_self_static_method,
         t: {
@@ -874,6 +935,28 @@ mod structure {
         expected: ["Hello", "F2"], ["0", "v2"]
     );
 
+    test_struct!(
+        format,
+        t: {
+            #[tabled(format = "{} cc")]
+            f1: u8,
+            f2: u8,
+        }
+        init: { f1: 0, f2: 0 }
+        expected: ["f1", "f2"], ["0 cc", "0"]
+    );
+
+    test_struct!(
+        format_with_args,
+        t: {
+            #[tabled(format("{}/{} cc/kg", self.f1, self.f2))]
+            f1: u8,
+            f2: u8,
+        }
+        init: { f1: 1, f2: 2 }
+        expected: ["f1", "f2"], ["1/2 cc/kg", "2"]
+    );
+
     // #[test]
     // fn order_compile_fail_when_order_is_bigger_then_count_fields() {
     //     #[derive(Tabled)]