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		quote! { pub #dissolved_field_name: #ty }
172	});
173
174	let field_moves = included_fields.iter().map(|(field, info)| {
175		// unwrap is safe because struct has named fields
176		let original_name = field.ident.as_ref().unwrap();
177
178		let dissolved_field_name = match &info.renamed_to {
179			Some(new_name) => new_name,
180			None => original_name,
181		};
182
183		quote! { #dissolved_field_name: self.#original_name }
184	});
185
186	let dissolved_struct_name = format_ident!("{}Dissolved", struct_name);
187
188	// Split generics for use in different positions
189	let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
190
191	Ok(quote! {
192		pub struct #dissolved_struct_name #impl_generics #where_clause {
193			#(#field_definitions),*
194		}
195
196		impl #impl_generics #struct_name #ty_generics #where_clause {
197			/// Dissolve this struct into its public-field equivalent.
198			///
199			/// This method consumes the original struct and returns a new struct where all included
200			/// fields are made public and optionally renamed.
201			pub fn dissolve(self) -> #dissolved_struct_name #ty_generics {
202				#dissolved_struct_name {
203					#(#field_moves),*
204				}
205			}
206		}
207	})
208}
209
210fn generate_tuple_struct_impl(
211	struct_name: &syn::Ident,
212	generics: &syn::Generics,
213	fields: &FieldsUnnamed,
214) -> Result<proc_macro2::TokenStream> {
215	// For tuple structs, only `skip` is supported (`rename` does not make sense)
216	let included_fields: Vec<_> = fields
217		.unnamed
218		.iter()
219		.enumerate()
220		.filter_map(|(index, field)| {
221			match get_field_info(field) {
222				Ok(info) => {
223					if info.should_skip {
224						None
225					} else {
226						// Check if rename was attempted on tuple struct
227						if info.renamed_to.is_some() {
228							Some(Err(Error::new_spanned(
229								field,
230								format!(
231									"{} is unsupported for tuple struct fields, only {} is allowed",
232									DissolvedOption::RENAME_IDENT,
233									DissolvedOption::SKIP_IDENT,
234								),
235							)))
236						} else {
237							Some(Ok((index, field)))
238						}
239					}
240				},
241				Err(err) => Some(Err(err)),
242			}
243		})
244		.collect::<Result<_>>()?;
245
246	if included_fields.is_empty() {
247		return Err(Error::new_spanned(
248			struct_name,
249			"cannot create dissolved tuple with no fields (all fields are skipped)",
250		));
251	}
252
253	let tuple_types = included_fields.iter().map(|(_, field)| &field.ty);
254	let tuple_type = if included_fields.len() == 1 {
255		// Single element tuple needs trailing comma
256		let ty = &included_fields[0].1.ty;
257		quote! { (#ty,) }
258	} else {
259		quote! { (#(#tuple_types),*) }
260	};
261
262	let field_moves = included_fields.iter().map(|(original_index, _)| {
263		let index = Index::from(*original_index);
264		quote! { self.#index }
265	});
266
267	let tuple_construction = if included_fields.len() == 1 {
268		// Single element tuple needs trailing comma
269		quote! { (#(#field_moves,)*) }
270	} else {
271		quote! { (#(#field_moves),*) }
272	};
273
274	// Split generics for use in different positions
275	let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
276
277	Ok(quote! {
278		impl #impl_generics #struct_name #ty_generics #where_clause {
279			/// Dissolve this tuple struct into a tuple of its included non-skipped fields.
280			pub fn dissolve(self) -> #tuple_type {
281				#tuple_construction
282			}
283		}
284	})
285}
286
287fn get_field_info(field: &Field) -> Result<FieldInfo> {
288	let mut field_info = FieldInfo::new();
289
290	for attr in field.attrs.iter().filter(|attr| attr.path().is_ident(DissolvedOption::IDENT)) {
291		match attr.meta.clone() {
292			Meta::List(_) => {
293				// Parse #[dissolved(skip)] or #[dissolved(rename = "new_name")]
294				let nested_metas = attr.parse_args_with(
295					syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
296				)?;
297
298				for nested_meta in nested_metas {
299					let option = DissolvedOption::from_meta(&nested_meta)?;
300					match option {
301						DissolvedOption::Skip => {
302							if field_info.renamed_to.is_some() {
303								return Err(Error::new_spanned(
304									attr,
305									format!(
306										"cannot use {} on skipped field",
307										DissolvedOption::RENAME_IDENT,
308									),
309								));
310							}
311
312							field_info.should_skip = true;
313						},
314						DissolvedOption::Rename(new_ident) => {
315							if field_info.should_skip {
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							if field_info.renamed_to.is_some() {
326								return Err(Error::new_spanned(
327									attr,
328									format!(
329										"cannot specify multiple {} options on the same field",
330										DissolvedOption::RENAME_IDENT,
331									),
332								));
333							}
334
335							field_info.renamed_to = Some(new_ident);
336						},
337					}
338				}
339			},
340			Meta::Path(_) => {
341				return Err(Error::new_spanned(
342					attr,
343					format!(
344						"dissolved attribute requires options, use #[dissolved({})] or #[dissolved({} = \"new_name\")] instead",
345						DissolvedOption::SKIP_IDENT,
346						DissolvedOption::RENAME_IDENT,
347					),
348				));
349			},
350			Meta::NameValue(_) => {
351				return Err(Error::new_spanned(
352					attr,
353					format!(
354						"dissolved attribute should use list syntax: #[dissolved({} = \"new_name\")] instead of #[dissolved = ...]",
355						DissolvedOption::RENAME_IDENT,
356					),
357				));
358			},
359		}
360	}
361
362	Ok(field_info)
363}