Skip to main content

toml_comment_derive/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::TokenStream as TokenStream2;
3use quote::quote;
4use syn::{Data, DeriveInput, Fields, PathArguments, Type};
5
6const LEAF_TYPES: &[&str] = &[
7    "bool", "u8", "u16", "u32", "u64", "u128", "i8", "i16", "i32", "i64", "i128", "f32", "f64",
8    "usize", "isize", "String",
9];
10
11fn emit_docs(docs: &[String]) -> Vec<TokenStream2> {
12    docs.iter()
13        .map(|doc| {
14            if doc.is_empty() {
15                quote! { out.push_str("#\n"); }
16            } else {
17                quote! { out.push_str(&format!("#{}\n", #doc)); }
18            }
19        })
20        .collect()
21}
22
23#[proc_macro_derive(TomlComment, attributes(toml_comment))]
24pub fn derive_toml_comment(input: TokenStream) -> TokenStream {
25    let input = syn::parse_macro_input!(input as DeriveInput);
26    let name = &input.ident;
27
28    let Data::Struct(data) = &input.data else {
29        return TokenStream::new();
30    };
31    let Fields::Named(named) = &data.fields else {
32        panic!("TomlComment only supports structs with named fields");
33    };
34
35    let struct_docs = extract_docs(&input.attrs);
36    let mut render_body: Vec<TokenStream2> = Vec::new();
37
38    let struct_doc_tokens = emit_docs(&struct_docs);
39    if !struct_doc_tokens.is_empty() {
40        render_body.extend(struct_doc_tokens);
41    }
42
43    let mut first_section = true;
44
45    for field in &named.named {
46        let field_name = field.ident.as_ref().expect("named field");
47        let field_name_str = field_name.to_string();
48        let field_docs = extract_docs(&field.attrs);
49        let force_inline = has_toml_comment_attr(&field.attrs, "inline");
50
51        if !force_inline && is_map_type(&field.ty) {
52            let doc_tokens = emit_docs(&field_docs);
53            render_body.push(quote! {
54                let map_val = toml::Value::try_from(&self.#field_name).unwrap();
55                if let toml::Value::Table(table) = map_val {
56                    if !table.is_empty() {
57                        #(#doc_tokens)*
58                        for (k, v) in &table {
59                            out.push_str(&format!("{} = {}\n", k, toml_comment::fmt_value(v)));
60                        }
61                    }
62                }
63            });
64        } else if !force_inline && is_section_type(&field.ty) {
65            let emit_blank = !first_section || !struct_docs.is_empty();
66            first_section = false;
67
68            render_body.push(quote! {
69                let section = if prefix.is_empty() {
70                    #field_name_str.to_string()
71                } else {
72                    format!("{}.{}", prefix, #field_name_str)
73                };
74            });
75
76            if emit_blank {
77                render_body.push(quote! { out.push('\n'); });
78            }
79
80            let doc_tokens = emit_docs(&field_docs);
81            render_body.extend(doc_tokens);
82
83            render_body.push(quote! {
84                out.push_str(&format!("[{}]\n", section));
85                self.#field_name._render(out, &section);
86            });
87        } else if is_option_type(&field.ty) {
88            let doc_tokens = emit_docs(&field_docs);
89            render_body.push(quote! {
90                if self.#field_name.is_some() {
91                    #(#doc_tokens)*
92                    let val = toml::Value::try_from(&self.#field_name).unwrap();
93                    out.push_str(&format!("{} = {}\n", #field_name_str, toml_comment::fmt_value(&val)));
94                }
95            });
96        } else {
97            render_body.extend(emit_docs(&field_docs));
98            render_body.push(quote! {
99                let val = toml::Value::try_from(&self.#field_name).unwrap();
100                out.push_str(&format!("{} = {}\n", #field_name_str, toml_comment::fmt_value(&val)));
101            });
102        }
103    }
104
105    quote! {
106        impl toml_comment::TomlComment for #name {
107            fn default_toml() -> String {
108                Self::default().to_commented_toml()
109            }
110
111            fn to_commented_toml(&self) -> String {
112                let mut out = String::new();
113                self._render(&mut out, "");
114                out
115            }
116
117            fn _render(&self, out: &mut String, prefix: &str) {
118                #(#render_body)*
119            }
120        }
121    }
122    .into()
123}
124
125fn extract_docs(attrs: &[syn::Attribute]) -> Vec<String> {
126    attrs
127        .iter()
128        .filter_map(|attr| {
129            if !attr.path().is_ident("doc") {
130                return None;
131            }
132            let syn::Meta::NameValue(nv) = &attr.meta else {
133                return None;
134            };
135            let syn::Expr::Lit(expr_lit) = &nv.value else {
136                return None;
137            };
138            let syn::Lit::Str(lit) = &expr_lit.lit else {
139                return None;
140            };
141            Some(lit.value())
142        })
143        .collect()
144}
145
146fn has_toml_comment_attr(attrs: &[syn::Attribute], name: &str) -> bool {
147    attrs.iter().any(|attr| {
148        attr.path().is_ident("toml_comment")
149            && matches!(&attr.meta, syn::Meta::List(list) if list.tokens.to_string().trim() == name)
150    })
151}
152
153fn is_section_type(ty: &Type) -> bool {
154    let Type::Path(type_path) = ty else {
155        return false;
156    };
157    let Some(seg) = type_path.path.segments.last() else {
158        return false;
159    };
160
161    if matches!(seg.arguments, PathArguments::AngleBracketed(_)) {
162        return false;
163    }
164
165    !LEAF_TYPES.contains(&seg.ident.to_string().as_str())
166}
167
168fn is_option_type(ty: &Type) -> bool {
169    let Type::Path(type_path) = ty else {
170        return false;
171    };
172    let Some(seg) = type_path.path.segments.last() else {
173        return false;
174    };
175    seg.ident == "Option"
176}
177
178fn is_map_type(ty: &Type) -> bool {
179    let Type::Path(type_path) = ty else {
180        return false;
181    };
182    let Some(seg) = type_path.path.segments.last() else {
183        return false;
184    };
185    seg.ident == "HashMap" || seg.ident == "BTreeMap"
186}