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
113	let Data::Struct(data_struct) = &input.data else {
114		return Err(Error::new_spanned(
115			input,
116			"Dissolve can only be derived for structs",
117		));
118	};
119
120	match &data_struct.fields {
121		Fields::Named(fields) => generate_named_struct_impl(struct_name, fields),
122		Fields::Unnamed(fields) => generate_tuple_struct_impl(struct_name, fields),
123		Fields::Unit => Err(Error::new_spanned(
124			input,
125			"Dissolve cannot be derived for unit structs",
126		)),
127	}
128}
129
130fn generate_named_struct_impl(
131	struct_name: &syn::Ident,
132	fields: &syn::FieldsNamed,
133) -> Result<proc_macro2::TokenStream> {
134	let included_fields: Vec<_> = fields
135		.named
136		.iter()
137		.map(|field| {
138			let info = get_field_info(field)?;
139			if info.should_skip {
140				Ok((None, info))
141			} else {
142				Ok((Some(field), info))
143			}
144		})
145		.filter_map(|res| match res {
146			Ok((Some(field), info)) => Some(Ok((field, info))),
147			Err(e) => Some(Err(e)),
148			_ => None,
149		})
150		.collect::<Result<_>>()?;
151
152	if included_fields.is_empty() {
153		return Err(Error::new_spanned(
154			struct_name,
155			"cannot create dissolved struct with no fields (all fields are skipped)",
156		));
157	}
158
159	let field_definitions = included_fields.iter().map(|(field, info)| {
160		// unwrap is safe because struct has named fields
161		let original_name = field.ident.as_ref().unwrap();
162		let ty = &field.ty;
163
164		let dissolved_field_name = match &info.renamed_to {
165			Some(new_name) => new_name,
166			None => original_name,
167		};
168
169		quote! { pub #dissolved_field_name: #ty }
170	});
171
172	let field_moves = included_fields.iter().map(|(field, info)| {
173		// unwrap is safe because struct has named fields
174		let original_name = field.ident.as_ref().unwrap();
175
176		let dissolved_field_name = match &info.renamed_to {
177			Some(new_name) => new_name,
178			None => original_name,
179		};
180
181		quote! { #dissolved_field_name: self.#original_name }
182	});
183
184	let dissolved_struct_name = format_ident!("{}Dissolved", struct_name);
185
186	Ok(quote! {
187		pub struct #dissolved_struct_name {
188			#(#field_definitions),*
189		}
190
191		impl #struct_name {
192			/// Dissolve this struct into its public-field equivalent.
193			///
194			/// This method consumes the original struct and returns a new struct where all included
195			/// fields are made public and optionally renamed.
196			pub fn dissolve(self) -> #dissolved_struct_name {
197				#dissolved_struct_name {
198					#(#field_moves),*
199				}
200			}
201		}
202	})
203}
204
205fn generate_tuple_struct_impl(
206	struct_name: &syn::Ident,
207	fields: &FieldsUnnamed,
208) -> Result<proc_macro2::TokenStream> {
209	// For tuple structs, only `skip` is supported (`rename` does not make sense)
210	let included_fields: Vec<_> = fields
211		.unnamed
212		.iter()
213		.enumerate()
214		.filter_map(|(index, field)| {
215			match get_field_info(field) {
216				Ok(info) => {
217					if info.should_skip {
218						None
219					} else {
220						// Check if rename was attempted on tuple struct
221						if info.renamed_to.is_some() {
222							Some(Err(Error::new_spanned(
223								field,
224								format!(
225									"{} is unsupported for tuple struct fields, only {} is allowed",
226									DissolvedOption::RENAME_IDENT,
227									DissolvedOption::SKIP_IDENT,
228								),
229							)))
230						} else {
231							Some(Ok((index, field)))
232						}
233					}
234				},
235				Err(err) => Some(Err(err)),
236			}
237		})
238		.collect::<Result<_>>()?;
239
240	if included_fields.is_empty() {
241		return Err(Error::new_spanned(
242			struct_name,
243			"cannot create dissolved tuple with no fields (all fields are skipped)",
244		));
245	}
246
247	let tuple_types = included_fields.iter().map(|(_, field)| &field.ty);
248	let tuple_type = if included_fields.len() == 1 {
249		// Single element tuple needs trailing comma
250		let ty = &included_fields[0].1.ty;
251		quote! { (#ty,) }
252	} else {
253		quote! { (#(#tuple_types),*) }
254	};
255
256	let field_moves = included_fields.iter().map(|(original_index, _)| {
257		let index = Index::from(*original_index);
258		quote! { self.#index }
259	});
260
261	let tuple_construction = if included_fields.len() == 1 {
262		// Single element tuple needs trailing comma
263		quote! { (#(#field_moves,)*) }
264	} else {
265		quote! { (#(#field_moves),*) }
266	};
267
268	Ok(quote! {
269		impl #struct_name {
270			/// Dissolve this tuple struct into a tuple of its included non-skipped fields.
271			pub fn dissolve(self) -> #tuple_type {
272				#tuple_construction
273			}
274		}
275	})
276}
277
278fn get_field_info(field: &Field) -> Result<FieldInfo> {
279	let mut field_info = FieldInfo::new();
280
281	for attr in field.attrs.iter().filter(|attr| attr.path().is_ident(DissolvedOption::IDENT)) {
282		match attr.meta.clone() {
283			Meta::List(_) => {
284				// Parse #[dissolved(skip)] or #[dissolved(rename = "new_name")]
285				let nested_metas = attr.parse_args_with(
286					syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
287				)?;
288
289				for nested_meta in nested_metas {
290					let option = DissolvedOption::from_meta(&nested_meta)?;
291					match option {
292						DissolvedOption::Skip => {
293							if field_info.renamed_to.is_some() {
294								return Err(Error::new_spanned(
295									attr,
296									format!(
297										"cannot use {} on skipped field",
298										DissolvedOption::RENAME_IDENT,
299									),
300								));
301							}
302
303							field_info.should_skip = true;
304						},
305						DissolvedOption::Rename(new_ident) => {
306							if field_info.should_skip {
307								return Err(Error::new_spanned(
308									attr,
309									format!(
310										"cannot use {} on skipped field",
311										DissolvedOption::RENAME_IDENT,
312									),
313								));
314							}
315
316							if field_info.renamed_to.is_some() {
317								return Err(Error::new_spanned(
318									attr,
319									format!(
320										"cannot specify multiple {} options on the same field",
321										DissolvedOption::RENAME_IDENT,
322									),
323								));
324							}
325
326							field_info.renamed_to = Some(new_ident);
327						},
328					}
329				}
330			},
331			Meta::Path(_) => {
332				return Err(Error::new_spanned(
333					attr,
334					format!(
335						"dissolved attribute requires options, use #[dissolved({})] or #[dissolved({} = \"new_name\")] instead",
336						DissolvedOption::SKIP_IDENT,
337						DissolvedOption::RENAME_IDENT,
338					),
339				));
340			},
341			Meta::NameValue(_) => {
342				return Err(Error::new_spanned(
343					attr,
344					format!(
345						"dissolved attribute should use list syntax: #[dissolved({} = \"new_name\")] instead of #[dissolved = ...]",
346						DissolvedOption::RENAME_IDENT,
347					),
348				));
349			},
350		}
351	}
352
353	Ok(field_info)
354}