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 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, §ion);
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}