dissolve_derive/
lib.rs

1use proc_macro::TokenStream;
2use quote::{format_ident, quote};
3use syn::{
4	Data, DeriveInput, Error, Expr, ExprLit, Field, Fields, FieldsUnnamed, Index, Lit, Meta,
5	MetaNameValue, Result, parse_macro_input,
6};
7
8/// Derive macro that generates a `dissolve(self)` method for structs.
9///
10/// For named structs, returns a struct with public fields named `{OriginalName}Dissolved`.
11/// For tuple structs, returns a tuple with the included fields.
12///
13/// # Attributes
14///
15/// - `#[dissolved(skip)]` - Skip this field in the dissolved struct/tuple
16/// - `#[dissolved(rename = "new_name")]` - Rename this field in the dissolved struct
17#[proc_macro_derive(Dissolve, attributes(dissolved))]
18pub fn derive_dissolve(input: TokenStream) -> TokenStream {
19	let input = parse_macro_input!(input as DeriveInput);
20
21	match generate_dissolve_impl(&input) {
22		Ok(tokens) => tokens.into(),
23		Err(err) => err.to_compile_error().into(),
24	}
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28enum DissolvedOption {
29	Skip,
30	Rename(syn::Ident),
31}
32
33#[derive(Debug, Clone)]
34struct FieldInfo {
35	should_skip: bool,
36	renamed_to: Option<syn::Ident>,
37}
38
39impl DissolvedOption {
40	const IDENT: &str = "dissolved";
41
42	const SKIP_IDENT: &str = "skip";
43
44	const RENAME_IDENT: &str = "rename";
45
46	fn from_meta(meta: &Meta) -> Result<Self> {
47		let unknown_attribute_err = |path: &syn::Path| {
48			let path_str = path
49				.segments
50				.iter()
51				.map(|seg| seg.ident.to_string())
52				.collect::<Vec<_>>()
53				.join("::");
54
55			Error::new_spanned(
56				path,
57				format!(
58					"unknown dissolved attribute option '{}'; supported options: {}, {} = \"new_name\"",
59					Self::SKIP_IDENT,
60					Self::RENAME_IDENT,
61					path_str,
62				),
63			)
64		};
65
66		let opt = match meta {
67			Meta::Path(path) => {
68				if !path.is_ident(Self::SKIP_IDENT) {
69					return Err(unknown_attribute_err(path));
70				}
71
72				DissolvedOption::Skip
73			},
74			Meta::NameValue(MetaNameValue { path, value, .. }) => {
75				if !path.is_ident(Self::RENAME_IDENT) {
76					return Err(unknown_attribute_err(path));
77				}
78
79				match value {
80					Expr::Lit(ExprLit { lit: Lit::Str(lit_str), .. }) => {
81						syn::parse_str::<syn::Ident>(&lit_str.value())
82							.map(DissolvedOption::Rename)?
83					},
84					_ => {
85						return Err(Error::new_spanned(
86							value,
87							format!("{} value must be a string literal", Self::RENAME_IDENT),
88						));
89					},
90				}
91			},
92			Meta::List(_) => {
93				return Err(Error::new_spanned(
94					meta,
95					"nested lists are not supported in dissolved attributes",
96				));
97			},
98		};
99
100		Ok(opt)
101	}
102}
103
104impl FieldInfo {
105	fn new() -> Self {
106		Self { should_skip: false, renamed_to: None }
107	}
108}
109
110fn generate_dissolve_impl(input: &DeriveInput) -> Result<proc_macro2::TokenStream> {
111	let struct_name = &input.ident;
112	let generics = &input.generics;
113
114	let Data::Struct(data_struct) = &input.data else {
115		return Err(Error::new_spanned(
116			input,
117			"Dissolve can only be derived for structs",
118		));
119	};
120
121	match &data_struct.fields {
122		Fields::Named(fields) => generate_named_struct_impl(struct_name, generics, fields),
123		Fields::Unnamed(fields) => generate_tuple_struct_impl(struct_name, generics, fields),
124		Fields::Unit => Err(Error::new_spanned(
125			input,
126			"Dissolve cannot be derived for unit structs",
127		)),
128	}
129}
130
131fn generate_named_struct_impl(
132	struct_name: &syn::Ident,
133	generics: &syn::Generics,
134	fields: &syn::FieldsNamed,
135) -> Result<proc_macro2::TokenStream> {
136	let included_fields: Vec<_> = fields
137		.named
138		.iter()
139		.map(|field| {
140			let info = get_field_info(field)?;
141			if info.should_skip {
142				Ok((None, info))
143			} else {
144				Ok((Some(field), info))
145			}
146		})
147		.filter_map(|res| match res {
148			Ok((Some(field), info)) => Some(Ok((field, info))),
149			Err(e) => Some(Err(e)),
150			_ => None,
151		})
152		.collect::<Result<_>>()?;
153
154	if included_fields.is_empty() {
155		return Err(Error::new_spanned(
156			struct_name,
157			"cannot create dissolved struct with no fields (all fields are skipped)",
158		));
159	}
160
161	let field_definitions = included_fields.iter().map(|(field, info)| {
162		// unwrap is safe because struct has named fields
163		let original_name = field.ident.as_ref().unwrap();
164		let ty = &field.ty;
165
166		let dissolved_field_name = match &info.renamed_to {
167			Some(new_name) => new_name,
168			None => original_name,
169		};
170
171		// Extract doc comments from the original field
172		let doc_attrs = field.attrs.iter().filter(|attr| attr.path().is_ident("doc"));
173
174		quote! {
175			#(#doc_attrs)*
176			pub #dissolved_field_name: #ty
177		}
178	});
179
180	let field_moves = included_fields.iter().map(|(field, info)| {
181		// unwrap is safe because struct has named fields
182		let original_name = field.ident.as_ref().unwrap();
183
184		let dissolved_field_name = match &info.renamed_to {
185			Some(new_name) => new_name,
186			None => original_name,
187		};
188
189		quote! { #dissolved_field_name: self.#original_name }
190	});
191
192	let dissolved_struct_name = format_ident!("{}Dissolved", struct_name);
193
194	// Split generics for use in different positions
195	let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
196
197	let dissolved_struct_doc = format!(
198		"Dissolved struct for [`{struct_name}`].\n\n\
199		This struct contains all non-skipped fields from the original struct with public visibility. \
200		Fields may be renamed according to `#[dissolved(rename = \"...\")]` attributes.",
201	);
202
203	Ok(quote! {
204		#[doc = #dissolved_struct_doc]
205		pub struct #dissolved_struct_name #impl_generics #where_clause {
206			#(#field_definitions),*
207		}
208
209		impl #impl_generics #struct_name #ty_generics #where_clause {
210			/// Dissolve this struct into its public-field equivalent.
211			///
212			/// This method consumes the original struct and returns a new struct where all included
213			/// fields are made public and optionally renamed.
214			pub fn dissolve(self) -> #dissolved_struct_name #ty_generics {
215				#dissolved_struct_name {
216					#(#field_moves),*
217				}
218			}
219		}
220	})
221}
222
223fn generate_tuple_struct_impl(
224	struct_name: &syn::Ident,
225	generics: &syn::Generics,
226	fields: &FieldsUnnamed,
227) -> Result<proc_macro2::TokenStream> {
228	// For tuple structs, only `skip` is supported (`rename` does not make sense)
229	let included_fields: Vec<_> = fields
230		.unnamed
231		.iter()
232		.enumerate()
233		.filter_map(|(index, field)| {
234			match get_field_info(field) {
235				Ok(info) => {
236					if info.should_skip {
237						None
238					} else {
239						// Check if rename was attempted on tuple struct
240						if info.renamed_to.is_some() {
241							Some(Err(Error::new_spanned(
242								field,
243								format!(
244									"{} is unsupported for tuple struct fields, only {} is allowed",
245									DissolvedOption::RENAME_IDENT,
246									DissolvedOption::SKIP_IDENT,
247								),
248							)))
249						} else {
250							Some(Ok((index, field)))
251						}
252					}
253				},
254				Err(err) => Some(Err(err)),
255			}
256		})
257		.collect::<Result<_>>()?;
258
259	if included_fields.is_empty() {
260		return Err(Error::new_spanned(
261			struct_name,
262			"cannot create dissolved tuple with no fields (all fields are skipped)",
263		));
264	}
265
266	let tuple_types = included_fields.iter().map(|(_, field)| &field.ty);
267	let tuple_type = if included_fields.len() == 1 {
268		// Single element tuple needs trailing comma
269		let ty = &included_fields[0].1.ty;
270		quote! { (#ty,) }
271	} else {
272		quote! { (#(#tuple_types),*) }
273	};
274
275	let field_moves = included_fields.iter().map(|(original_index, _)| {
276		let index = Index::from(*original_index);
277		quote! { self.#index }
278	});
279
280	let tuple_construction = if included_fields.len() == 1 {
281		// Single element tuple needs trailing comma
282		quote! { (#(#field_moves,)*) }
283	} else {
284		quote! { (#(#field_moves),*) }
285	};
286
287	// Split generics for use in different positions
288	let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
289
290	Ok(quote! {
291		impl #impl_generics #struct_name #ty_generics #where_clause {
292			/// Dissolve this tuple struct into a tuple of its included non-skipped fields.
293			pub fn dissolve(self) -> #tuple_type {
294				#tuple_construction
295			}
296		}
297	})
298}
299
300fn get_field_info(field: &Field) -> Result<FieldInfo> {
301	let mut field_info = FieldInfo::new();
302
303	for attr in field.attrs.iter().filter(|attr| attr.path().is_ident(DissolvedOption::IDENT)) {
304		match attr.meta.clone() {
305			Meta::List(_) => {
306				// Parse #[dissolved(skip)] or #[dissolved(rename = "new_name")]
307				let nested_metas = attr.parse_args_with(
308					syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
309				)?;
310
311				for nested_meta in nested_metas {
312					let option = DissolvedOption::from_meta(&nested_meta)?;
313					match option {
314						DissolvedOption::Skip => {
315							if field_info.renamed_to.is_some() {
316								return Err(Error::new_spanned(
317									attr,
318									format!(
319										"cannot use {} on skipped field",
320										DissolvedOption::RENAME_IDENT,
321									),
322								));
323							}
324
325							field_info.should_skip = true;
326						},
327						DissolvedOption::Rename(new_ident) => {
328							if field_info.should_skip {
329								return Err(Error::new_spanned(
330									attr,
331									format!(
332										"cannot use {} on skipped field",
333										DissolvedOption::RENAME_IDENT,
334									),
335								));
336							}
337
338							if field_info.renamed_to.is_some() {
339								return Err(Error::new_spanned(
340									attr,
341									format!(
342										"cannot specify multiple {} options on the same field",
343										DissolvedOption::RENAME_IDENT,
344									),
345								));
346							}
347
348							field_info.renamed_to = Some(new_ident);
349						},
350					}
351				}
352			},
353			Meta::Path(_) => {
354				return Err(Error::new_spanned(
355					attr,
356					format!(
357						"dissolved attribute requires options, use #[dissolved({})] or #[dissolved({} = \"new_name\")] instead",
358						DissolvedOption::SKIP_IDENT,
359						DissolvedOption::RENAME_IDENT,
360					),
361				));
362			},
363			Meta::NameValue(_) => {
364				return Err(Error::new_spanned(
365					attr,
366					format!(
367						"dissolved attribute should use list syntax: #[dissolved({} = \"new_name\")] instead of #[dissolved = ...]",
368						DissolvedOption::RENAME_IDENT,
369					),
370				));
371			},
372		}
373	}
374
375	Ok(field_info)
376}