dissolve_derive/
lib.rs

1//! # Dissolve Derive
2//!
3//! A procedural macro for safely taking ownership of inner fields from a struct without exposing
4//! those fields publicly.
5//!
6//! ## Motivation
7//!
8//! The `dissolve-derive` proc macro solves a specific problem: when you have a struct with
9//! private fields and need to transfer ownership of those fields to another part of your code,
10//! you often face two undesirable choices:
11//!
12//! 1. **Make fields public**: This exposes your internal state and allows arbitrary mutation,
13//!    breaking encapsulation.
14//! 2. **Write accessor methods**: This requires boilerplate code and may involve cloning data,
15//!    which is inefficient for large structures.
16//!
17//! The `Dissolve` derive macro provides a `dissolve(self)` method that consumes the struct and
18//! returns its fields in a type-safe manner. This approach:
19//!
20//! - **Preserves encapsulation**: Fields remain private in the original struct
21//! - **Enables efficient ownership transfer**: No cloning required, fields are moved
22//! - **Prevents misuse**: The dissolved struct is a different type, preventing it from being used
23//!   where the original struct is expected
24//! - **Provides flexibility**: Control which fields are exposed and rename them if needed
25//! - **Allows custom visibility**: Configure the visibility of the `dissolve` method itself
26//!
27//! ## Use Cases
28//!
29//! ### 1. API Boundaries
30//!
31//! When building a library, you want to keep internal structure private but allow consumers
32//! to extract owned data when they're done with the struct's instance:
33//!
34//! ```rust
35//! use dissolve_derive::Dissolve;
36//!
37//! #[derive(Dissolve)]
38//! pub struct Connection {
39//!     // Private: users can't modify the socket directly
40//!     socket: std::net::TcpStream,
41//!
42//!     // Private: internal state
43//!     buffer: Vec<u8>,
44//!
45//!     // Skip: purely internal, never exposed
46//!     #[dissolved(skip)]
47//!     statistics: ConnectionStats,
48//! }
49//!
50//! # struct ConnectionStats;
51//!
52//! // Users can dissolve the connection to reclaim the socket
53//! // without having public access to it during normal operation
54//! # fn example(conn: Connection) {
55//! let ConnectionDissolved { socket, buffer } = conn.dissolve();
56//! # }
57//! ```
58//!
59//! ### 2. Builder Pattern Finalization
60//!
61//! Use dissolve to finalize a builder and extract components with controlled visibility:
62//!
63//! ```rust
64//! use dissolve_derive::Dissolve;
65//!
66//! #[derive(Dissolve)]
67//! #[dissolve(visibility = "pub(crate)")]
68//! pub struct ConfigBuilder {
69//!     database_url: String,
70//!     max_connections: u32,
71//!
72//!     #[dissolved(skip)]
73//!     validated: bool,
74//! }
75//!
76//! impl ConfigBuilder {
77//!     pub fn build(mut self) -> Config {
78//!         self.validated = true;
79//!         // Only accessible within the crate due to pub(crate)
80//!         let ConfigBuilderDissolved { database_url, max_connections } = self.dissolve();
81//!         Config { database_url, max_connections }
82//!     }
83//! }
84//! # pub struct Config { database_url: String, max_connections: u32 }
85//! ```
86//!
87//! ### 3. State Machine Transitions
88//!
89//! Safely transition between states by dissolving one state struct and constructing the next:
90//!
91//! ```rust
92//! use std::time::Instant;
93//!
94//! use dissolve_derive::Dissolve;
95//!
96//! #[derive(Dissolve)]
97//! struct PendingRequest {
98//!     request_id: u64,
99//!     payload: Vec<u8>,
100//!
101//!     #[dissolved(skip)]
102//!     timestamp: Instant,
103//! }
104//!
105//! #[derive(Dissolve)]
106//! struct ProcessedRequest {
107//!     request_id: u64,
108//!     response: Vec<u8>,
109//! }
110//!
111//! impl PendingRequest {
112//!     fn process(self) -> ProcessedRequest {
113//!         let PendingRequestDissolved { request_id, payload } = self.dissolve();
114//!         let response = process_payload(payload);
115//!         ProcessedRequest { request_id, response }
116//!     }
117//! }
118//!
119//! # fn process_payload(p: Vec<u8>) -> Vec<u8> { p }
120//! ```
121//!
122//! ### 4. Zero-Cost Abstraction Unwrapping
123//!
124//! When wrapping types for compile-time guarantees, use dissolve for efficient unwrapping:
125//!
126//! ```rust
127//! use dissolve_derive::Dissolve;
128//!
129//! #[derive(Dissolve)]
130//! pub struct Validated<T> {
131//!     inner: T,
132//!
133//!     #[dissolved(skip)]
134//!     validation_token: ValidationToken,
135//! }
136//!
137//! impl<T> Validated<T> {
138//!     pub fn into_inner(self) -> T {
139//!         self.dissolve().inner
140//!     }
141//! }
142//!
143//! # struct ValidationToken;
144//! ```
145//!
146//! ## Attributes
147//!
148//! ### Container Attributes (on structs)
149//!
150//! - `#[dissolve(visibility = "...")]` - Set the visibility of the `dissolve` method
151//!   - Supported values: `"pub"`, `"pub(crate)"`, `"pub(super)"`, `"pub(self)"`, or empty string for private
152//!   - Default: `"pub"` if not specified
153//!
154//! ### Field Attributes
155//!
156//! - `#[dissolved(skip)]` - Skip this field in the dissolved output
157//! - `#[dissolved(rename = "new_name")]` - Rename this field in the dissolved struct (named structs only)
158//!
159//! ## Examples
160//!
161//! ### Basic Usage
162//!
163//! ```rust
164//! use dissolve_derive::Dissolve;
165//!
166//! #[derive(Dissolve)]
167//! struct User {
168//!     name: String,
169//!     email: String,
170//! }
171//!
172//! let user = User {
173//!     name: "alice".to_string(),
174//!     email: "alice@example.com".to_string(),
175//! };
176//!
177//! let UserDissolved { name, email } = user.dissolve();
178//! assert_eq!(name, "alice");
179//! ```
180//!
181//! ### With Custom Visibility
182//!
183//! ```rust
184//! use dissolve_derive::Dissolve;
185//!
186//! #[derive(Dissolve)]
187//! #[dissolve(visibility = "pub(crate)")]
188//! pub struct InternalData {
189//!     value: i32,
190//! }
191//!
192//! // The dissolve method is only accessible within the same crate
193//! ```
194//!
195//! ### Skipping Fields
196//!
197//! ```rust
198//! use dissolve_derive::Dissolve;
199//!
200//! #[derive(Dissolve)]
201//! struct Credentials {
202//!     username: String,
203//!
204//!     #[dissolved(skip)]
205//!     password: String,  // Never exposed, even through dissolve
206//! }
207//! ```
208//!
209//! ### Renaming Fields
210//!
211//! ```rust
212//! use dissolve_derive::Dissolve;
213//!
214//! #[derive(Dissolve)]
215//! struct ApiResponse {
216//!     #[dissolved(rename = "user_id")]
217//!     id: u64,
218//!
219//!     #[dissolved(rename = "user_name")]
220//!     name: String,
221//! }
222//! ```
223//!
224//! ### Tuple Structs
225//!
226//! ```rust
227//! use dissolve_derive::Dissolve;
228//!
229//! #[derive(Dissolve)]
230//! struct Coordinate(f64, f64, #[dissolved(skip)] String);
231//!
232//! let coord = Coordinate(1.0, 2.0, "label".to_string());
233//! let (x, y) = coord.dissolve();
234//! ```
235
236use proc_macro::TokenStream;
237use quote::{format_ident, quote};
238use syn::{
239	Data, DeriveInput, Error, Expr, ExprLit, Field, Fields, FieldsUnnamed, Index, Lit, Meta,
240	MetaNameValue, Result, parse_macro_input,
241};
242
243/// Derive macro that generates a `dissolve(self)` method for structs.
244///
245/// For named structs, returns a struct with public fields named `{OriginalName}Dissolved`.
246/// For tuple structs, returns a tuple with the included fields.
247///
248/// # Attributes
249///
250/// - `#[dissolved(skip)]` - Skip this field in the dissolved struct/tuple
251/// - `#[dissolved(rename = "new_name")]` - Rename this field in the dissolved struct
252#[proc_macro_derive(Dissolve, attributes(dissolve, dissolved))]
253pub fn derive_dissolve(input: TokenStream) -> TokenStream {
254	let input = parse_macro_input!(input as DeriveInput);
255
256	match generate_dissolve_impl(&input) {
257		Ok(tokens) => tokens.into(),
258		Err(err) => err.to_compile_error().into(),
259	}
260}
261
262#[derive(Debug, Clone)]
263struct ContainerAttributes {
264	visibility: syn::Visibility,
265}
266
267impl ContainerAttributes {
268	const IDENT: &str = "dissolve";
269
270	const VISIBILITY_IDENT: &str = "visibility";
271
272	fn from_derive_input(input: &DeriveInput) -> Result<Self> {
273		let mut visibility = syn::parse_str::<syn::Visibility>("pub").unwrap();
274
275		for attr in input.attrs.iter().filter(|attr| attr.path().is_ident(Self::IDENT)) {
276			match &attr.meta {
277				Meta::List(_) => {
278					let nested_metas = attr.parse_args_with(
279						syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
280					)?;
281
282					for nested_meta in nested_metas {
283						match &nested_meta {
284							Meta::NameValue(MetaNameValue { path, value, .. }) => {
285								if path.is_ident(Self::VISIBILITY_IDENT) {
286									match value {
287										Expr::Lit(ExprLit { lit: Lit::Str(lit_str), .. }) => {
288											let vis_str = lit_str.value();
289											visibility = syn::parse_str::<syn::Visibility>(&vis_str)
290												.map_err(|e| {
291													Error::new_spanned(
292														value,
293														format!(
294															"invalid visibility: {e}. Supported: 'pub', 'pub(crate)', 'pub(super)', 'pub(self)' or empty for private",
295														),
296													)
297												})?;
298										},
299										_ => {
300											return Err(Error::new_spanned(
301												value,
302												"visibility value must be a string literal",
303											));
304										},
305									}
306								} else {
307									return Err(Error::new_spanned(
308										path,
309										format!(
310											"unknown dissolve attribute option '{}'; supported option: {}",
311											path.get_ident()
312												.map(|i| i.to_string())
313												.unwrap_or_default(),
314											Self::VISIBILITY_IDENT,
315										),
316									));
317								}
318							},
319							_ => {
320								return Err(Error::new_spanned(
321									nested_meta,
322									"dissolve container attribute must use name-value syntax: #[dissolve(visibility = \"...\")]",
323								));
324							},
325						}
326					}
327				},
328				_ => {
329					return Err(Error::new_spanned(
330						attr,
331						"dissolve attribute must use list syntax: #[dissolve(visibility = \"...\")]",
332					));
333				},
334			}
335		}
336
337		Ok(Self { visibility })
338	}
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
342enum DissolvedOption {
343	Skip,
344	Rename(syn::Ident),
345}
346
347#[derive(Debug, Clone)]
348struct FieldInfo {
349	should_skip: bool,
350	renamed_to: Option<syn::Ident>,
351}
352
353impl DissolvedOption {
354	const IDENT: &str = "dissolved";
355
356	const SKIP_IDENT: &str = "skip";
357
358	const RENAME_IDENT: &str = "rename";
359
360	fn from_meta(meta: &Meta) -> Result<Self> {
361		let unknown_attribute_err = |path: &syn::Path| {
362			let path_str = path
363				.segments
364				.iter()
365				.map(|seg| seg.ident.to_string())
366				.collect::<Vec<_>>()
367				.join("::");
368
369			Error::new_spanned(
370				path,
371				format!(
372					"unknown dissolved attribute option '{}'; supported options: {}, {} = \"new_name\"",
373					Self::SKIP_IDENT,
374					Self::RENAME_IDENT,
375					path_str,
376				),
377			)
378		};
379
380		let opt = match meta {
381			Meta::Path(path) => {
382				if !path.is_ident(Self::SKIP_IDENT) {
383					return Err(unknown_attribute_err(path));
384				}
385
386				DissolvedOption::Skip
387			},
388			Meta::NameValue(MetaNameValue { path, value, .. }) => {
389				if !path.is_ident(Self::RENAME_IDENT) {
390					return Err(unknown_attribute_err(path));
391				}
392
393				match value {
394					Expr::Lit(ExprLit { lit: Lit::Str(lit_str), .. }) => {
395						syn::parse_str::<syn::Ident>(&lit_str.value())
396							.map(DissolvedOption::Rename)?
397					},
398					_ => {
399						return Err(Error::new_spanned(
400							value,
401							format!("{} value must be a string literal", Self::RENAME_IDENT),
402						));
403					},
404				}
405			},
406			Meta::List(_) => {
407				return Err(Error::new_spanned(
408					meta,
409					"nested lists are not supported in dissolved attributes",
410				));
411			},
412		};
413
414		Ok(opt)
415	}
416}
417
418impl FieldInfo {
419	fn new() -> Self {
420		Self { should_skip: false, renamed_to: None }
421	}
422}
423
424fn generate_dissolve_impl(input: &DeriveInput) -> Result<proc_macro2::TokenStream> {
425	let struct_name = &input.ident;
426	let generics = &input.generics;
427	let container_attrs = ContainerAttributes::from_derive_input(input)?;
428
429	let Data::Struct(data_struct) = &input.data else {
430		return Err(Error::new_spanned(
431			input,
432			"Dissolve can only be derived for structs",
433		));
434	};
435
436	match &data_struct.fields {
437		Fields::Named(fields) => {
438			generate_named_struct_impl(struct_name, generics, fields, &container_attrs)
439		},
440		Fields::Unnamed(fields) => {
441			generate_tuple_struct_impl(struct_name, generics, fields, &container_attrs)
442		},
443		Fields::Unit => Err(Error::new_spanned(
444			input,
445			"Dissolve cannot be derived for unit structs",
446		)),
447	}
448}
449
450fn generate_named_struct_impl(
451	struct_name: &syn::Ident,
452	generics: &syn::Generics,
453	fields: &syn::FieldsNamed,
454	container_attrs: &ContainerAttributes,
455) -> Result<proc_macro2::TokenStream> {
456	let included_fields: Vec<_> = fields
457		.named
458		.iter()
459		.map(|field| {
460			let info = get_field_info(field)?;
461			if info.should_skip {
462				Ok((None, info))
463			} else {
464				Ok((Some(field), info))
465			}
466		})
467		.filter_map(|res| match res {
468			Ok((Some(field), info)) => Some(Ok((field, info))),
469			Err(e) => Some(Err(e)),
470			_ => None,
471		})
472		.collect::<Result<_>>()?;
473
474	if included_fields.is_empty() {
475		return Err(Error::new_spanned(
476			struct_name,
477			"cannot create dissolved struct with no fields (all fields are skipped)",
478		));
479	}
480
481	let field_definitions = included_fields.iter().map(|(field, info)| {
482		// unwrap is safe because struct has named fields
483		let original_name = field.ident.as_ref().unwrap();
484		let ty = &field.ty;
485
486		let dissolved_field_name = match &info.renamed_to {
487			Some(new_name) => new_name,
488			None => original_name,
489		};
490
491		// Extract doc comments from the original field
492		let doc_attrs = field.attrs.iter().filter(|attr| attr.path().is_ident("doc"));
493
494		quote! {
495			#(#doc_attrs)*
496			pub #dissolved_field_name: #ty
497		}
498	});
499
500	let field_moves = included_fields.iter().map(|(field, info)| {
501		// unwrap is safe because struct has named fields
502		let original_name = field.ident.as_ref().unwrap();
503
504		let dissolved_field_name = match &info.renamed_to {
505			Some(new_name) => new_name,
506			None => original_name,
507		};
508
509		quote! { #dissolved_field_name: self.#original_name }
510	});
511
512	let dissolved_struct_name = format_ident!("{}Dissolved", struct_name);
513
514	// Split generics for use in different positions
515	let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
516
517	let dissolved_struct_doc = format!(
518		"Dissolved struct for [`{struct_name}`].\n\n\
519		This struct contains all non-skipped fields from the original struct with public visibility. \
520		Fields may be renamed according to `#[dissolved(rename = \"...\")]` attributes.",
521	);
522
523	let visibility = &container_attrs.visibility;
524
525	Ok(quote! {
526		#[doc = #dissolved_struct_doc]
527		pub struct #dissolved_struct_name #impl_generics #where_clause {
528			#(#field_definitions),*
529		}
530
531		impl #impl_generics #struct_name #ty_generics #where_clause {
532			/// Dissolve this struct into its public-field equivalent.
533			///
534			/// This method consumes the original struct and returns a new struct where all included
535			/// fields are made public and optionally renamed.
536			#visibility fn dissolve(self) -> #dissolved_struct_name #ty_generics {
537				#dissolved_struct_name {
538					#(#field_moves),*
539				}
540			}
541		}
542	})
543}
544
545fn generate_tuple_struct_impl(
546	struct_name: &syn::Ident,
547	generics: &syn::Generics,
548	fields: &FieldsUnnamed,
549	container_attrs: &ContainerAttributes,
550) -> Result<proc_macro2::TokenStream> {
551	// For tuple structs, only `skip` is supported (`rename` does not make sense)
552	let included_fields: Vec<_> = fields
553		.unnamed
554		.iter()
555		.enumerate()
556		.filter_map(|(index, field)| {
557			match get_field_info(field) {
558				Ok(info) => {
559					if info.should_skip {
560						None
561					} else {
562						// Check if rename was attempted on tuple struct
563						if info.renamed_to.is_some() {
564							Some(Err(Error::new_spanned(
565								field,
566								format!(
567									"{} is unsupported for tuple struct fields, only {} is allowed",
568									DissolvedOption::RENAME_IDENT,
569									DissolvedOption::SKIP_IDENT,
570								),
571							)))
572						} else {
573							Some(Ok((index, field)))
574						}
575					}
576				},
577				Err(err) => Some(Err(err)),
578			}
579		})
580		.collect::<Result<_>>()?;
581
582	if included_fields.is_empty() {
583		return Err(Error::new_spanned(
584			struct_name,
585			"cannot create dissolved tuple with no fields (all fields are skipped)",
586		));
587	}
588
589	let tuple_types = included_fields.iter().map(|(_, field)| &field.ty);
590	let tuple_type = if included_fields.len() == 1 {
591		// Single element tuple needs trailing comma
592		let ty = &included_fields[0].1.ty;
593		quote! { (#ty,) }
594	} else {
595		quote! { (#(#tuple_types),*) }
596	};
597
598	let field_moves = included_fields.iter().map(|(original_index, _)| {
599		let index = Index::from(*original_index);
600		quote! { self.#index }
601	});
602
603	let tuple_construction = if included_fields.len() == 1 {
604		// Single element tuple needs trailing comma
605		quote! { (#(#field_moves,)*) }
606	} else {
607		quote! { (#(#field_moves),*) }
608	};
609
610	// Split generics for use in different positions
611	let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
612	let visibility = &container_attrs.visibility;
613
614	Ok(quote! {
615		impl #impl_generics #struct_name #ty_generics #where_clause {
616			/// Dissolve this tuple struct into a tuple of its included non-skipped fields.
617			#visibility fn dissolve(self) -> #tuple_type {
618				#tuple_construction
619			}
620		}
621	})
622}
623
624fn get_field_info(field: &Field) -> Result<FieldInfo> {
625	let mut field_info = FieldInfo::new();
626
627	for attr in field.attrs.iter().filter(|attr| attr.path().is_ident(DissolvedOption::IDENT)) {
628		match attr.meta.clone() {
629			Meta::List(_) => {
630				// Parse #[dissolved(skip)] or #[dissolved(rename = "new_name")]
631				let nested_metas = attr.parse_args_with(
632					syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
633				)?;
634
635				for nested_meta in nested_metas {
636					let option = DissolvedOption::from_meta(&nested_meta)?;
637					match option {
638						DissolvedOption::Skip => {
639							if field_info.renamed_to.is_some() {
640								return Err(Error::new_spanned(
641									attr,
642									format!(
643										"cannot use {} on skipped field",
644										DissolvedOption::RENAME_IDENT,
645									),
646								));
647							}
648
649							field_info.should_skip = true;
650						},
651						DissolvedOption::Rename(new_ident) => {
652							if field_info.should_skip {
653								return Err(Error::new_spanned(
654									attr,
655									format!(
656										"cannot use {} on skipped field",
657										DissolvedOption::RENAME_IDENT,
658									),
659								));
660							}
661
662							if field_info.renamed_to.is_some() {
663								return Err(Error::new_spanned(
664									attr,
665									format!(
666										"cannot specify multiple {} options on the same field",
667										DissolvedOption::RENAME_IDENT,
668									),
669								));
670							}
671
672							field_info.renamed_to = Some(new_ident);
673						},
674					}
675				}
676			},
677			Meta::Path(_) => {
678				return Err(Error::new_spanned(
679					attr,
680					format!(
681						"dissolved attribute requires options, use #[dissolved({})] or #[dissolved({} = \"new_name\")] instead",
682						DissolvedOption::SKIP_IDENT,
683						DissolvedOption::RENAME_IDENT,
684					),
685				));
686			},
687			Meta::NameValue(_) => {
688				return Err(Error::new_spanned(
689					attr,
690					format!(
691						"dissolved attribute should use list syntax: #[dissolved({} = \"new_name\")] instead of #[dissolved = ...]",
692						DissolvedOption::RENAME_IDENT,
693					),
694				));
695			},
696		}
697	}
698
699	Ok(field_info)
700}