toml_comment_derive/
lib.rs1use 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, §ion);
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}