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        panic!("TomlComment only supports structs");
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_section_type(&field.ty) {
52            let emit_blank = !first_section || !struct_docs.is_empty();
53            first_section = false;
54
55            render_body.push(quote! {
56                let section = if prefix.is_empty() {
57                    #field_name_str.to_string()
58                } else {
59                    format!("{}.{}", prefix, #field_name_str)
60                };
61            });
62
63            if emit_blank {
64                render_body.push(quote! { out.push('\n'); });
65            }
66
67            let doc_tokens = emit_docs(&field_docs);
68            render_body.extend(doc_tokens);
69
70            render_body.push(quote! {
71                out.push_str(&format!("[{}]\n", section));
72                self.#field_name._render(out, &section);
73            });
74        } else if is_option_type(&field.ty) {
75            let doc_tokens = emit_docs(&field_docs);
76            render_body.push(quote! {
77                if self.#field_name.is_some() {
78                    #(#doc_tokens)*
79                    let val = toml::Value::try_from(&self.#field_name).unwrap();
80                    out.push_str(&format!("{} = {}\n", #field_name_str, toml_comment::fmt_value(&val)));
81                }
82            });
83        } else {
84            render_body.extend(emit_docs(&field_docs));
85            render_body.push(quote! {
86                let val = toml::Value::try_from(&self.#field_name).unwrap();
87                out.push_str(&format!("{} = {}\n", #field_name_str, toml_comment::fmt_value(&val)));
88            });
89        }
90    }
91
92    quote! {
93        impl toml_comment::TomlComment for #name {
94            fn default_toml() -> String {
95                Self::default().to_commented_toml()
96            }
97
98            fn to_commented_toml(&self) -> String {
99                let mut out = String::new();
100                self._render(&mut out, "");
101                out
102            }
103
104            fn _render(&self, out: &mut String, prefix: &str) {
105                #(#render_body)*
106            }
107        }
108    }
109    .into()
110}
111
112fn extract_docs(attrs: &[syn::Attribute]) -> Vec<String> {
113    attrs
114        .iter()
115        .filter_map(|attr| {
116            if !attr.path().is_ident("doc") {
117                return None;
118            }
119            let syn::Meta::NameValue(nv) = &attr.meta else {
120                return None;
121            };
122            let syn::Expr::Lit(expr_lit) = &nv.value else {
123                return None;
124            };
125            let syn::Lit::Str(lit) = &expr_lit.lit else {
126                return None;
127            };
128            Some(lit.value())
129        })
130        .collect()
131}
132
133fn has_toml_comment_attr(attrs: &[syn::Attribute], name: &str) -> bool {
134    attrs.iter().any(|attr| {
135        attr.path().is_ident("toml_comment")
136            && matches!(&attr.meta, syn::Meta::List(list) if list.tokens.to_string().trim() == name)
137    })
138}
139
140fn is_section_type(ty: &Type) -> bool {
141    let Type::Path(type_path) = ty else {
142        return false;
143    };
144    let Some(seg) = type_path.path.segments.last() else {
145        return false;
146    };
147
148    if matches!(seg.arguments, PathArguments::AngleBracketed(_)) {
149        return false;
150    }
151
152    !LEAF_TYPES.contains(&seg.ident.to_string().as_str())
153}
154
155fn is_option_type(ty: &Type) -> bool {
156    let Type::Path(type_path) = ty else {
157        return false;
158    };
159    let Some(seg) = type_path.path.segments.last() else {
160        return false;
161    };
162    seg.ident == "Option"
163}