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 both the `dissolve` method and the generated dissolved struct
151//!   - Supported values: `"pub"`, `"pub(crate)"`, `"pub(super)"`, `"pub(self)"`, or empty string for private
152//!   - Default: `"pub"` if not specified
153//!   - Note: The dissolved struct (`{StructName}Dissolved`) will have the same visibility as the `dissolve` method
154//!
155//! ### Field Attributes
156//!
157//! - `#[dissolved(skip)]` - Skip this field in the dissolved output
158//! - `#[dissolved(rename = "new_name")]` - Rename this field in the dissolved struct (named structs only)
159//!
160//! ## Examples
161//!
162//! ### Basic Usage
163//!
164//! ```rust
165//! use dissolve_derive::Dissolve;
166//!
167//! #[derive(Dissolve)]
168//! struct User {
169//!     name: String,
170//!     email: String,
171//! }
172//!
173//! let user = User {
174//!     name: "alice".to_string(),
175//!     email: "alice@example.com".to_string(),
176//! };
177//!
178//! let UserDissolved { name, email } = user.dissolve();
179//! assert_eq!(name, "alice");
180//! ```
181//!
182//! ### With Custom Visibility
183//!
184//! ```rust
185//! use dissolve_derive::Dissolve;
186//!
187//! #[derive(Dissolve)]
188//! #[dissolve(visibility = "pub(crate)")]
189//! pub struct InternalData {
190//!     value: i32,
191//! }
192//!
193//! // Both the dissolve method and InternalDataDissolved struct
194//! // are only accessible within the same crate
195//! # fn example(data: InternalData) {
196//! let InternalDataDissolved { value } = data.dissolve();
197//! # }
198//! ```
199//!
200//! ### Skipping Fields
201//!
202//! ```rust
203//! use dissolve_derive::Dissolve;
204//!
205//! #[derive(Dissolve)]
206//! struct Credentials {
207//!     username: String,
208//!
209//!     #[dissolved(skip)]
210//!     password: String,  // Never exposed, even through dissolve
211//! }
212//! ```
213//!
214//! ### Renaming Fields
215//!
216//! ```rust
217//! use dissolve_derive::Dissolve;
218//!
219//! #[derive(Dissolve)]
220//! struct ApiResponse {
221//!     #[dissolved(rename = "user_id")]
222//!     id: u64,
223//!
224//!     #[dissolved(rename = "user_name")]
225//!     name: String,
226//! }
227//! ```
228//!
229//! ### Tuple Structs
230//!
231//! ```rust
232//! use dissolve_derive::Dissolve;
233//!
234//! #[derive(Dissolve)]
235//! struct Coordinate(f64, f64, #[dissolved(skip)] String);
236//!
237//! let coord = Coordinate(1.0, 2.0, "label".to_string());
238//! let (x, y) = coord.dissolve();
239//! ```
240
241use proc_macro::TokenStream;
242use quote::{format_ident, quote};
243use syn::{
244	Data, DeriveInput, Error, Expr, ExprLit, Field, Fields, FieldsUnnamed, Index, Lit, Meta,
245	MetaNameValue, Result, parse_macro_input,
246};
247
248/// Derive macro that generates a `dissolve(self)` method for structs.
249///
250/// For named structs, returns a struct with public fields named `{OriginalName}Dissolved`.
251/// For tuple structs, returns a tuple with the included fields.
252///
253/// # Attributes
254///
255/// - `#[dissolved(skip)]` - Skip this field in the dissolved struct/tuple
256/// - `#[dissolved(rename = "new_name")]` - Rename this field in the dissolved struct
257#[proc_macro_derive(Dissolve, attributes(dissolve, dissolved))]
258pub fn derive_dissolve(input: TokenStream) -> TokenStream {
259	let input = parse_macro_input!(input as DeriveInput);
260
261	match generate_dissolve_impl(&input) {
262		Ok(tokens) => tokens.into(),
263		Err(err) => err.to_compile_error().into(),
264	}
265}
266
267#[derive(Debug, Clone)]
268struct ContainerAttributes {
269	visibility: syn::Visibility,
270}
271
272impl ContainerAttributes {
273	const IDENT: &str = "dissolve";
274
275	const VISIBILITY_IDENT: &str = "visibility";
276
277	fn from_derive_input(input: &DeriveInput) -> Result<Self> {
278		let mut visibility = syn::parse_str::<syn::Visibility>("pub").unwrap();
279
280		for attr in input.attrs.iter().filter(|attr| attr.path().is_ident(Self::IDENT)) {
281			match &attr.meta {
282				Meta::List(_) => {
283					let nested_metas = attr.parse_args_with(
284						syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
285					)?;
286
287					for nested_meta in nested_metas {
288						match &nested_meta {
289							Meta::NameValue(MetaNameValue { path, value, .. }) => {
290								if path.is_ident(Self::VISIBILITY_IDENT) {
291									match value {
292										Expr::Lit(ExprLit { lit: Lit::Str(lit_str), .. }) => {
293											let vis_str = lit_str.value();
294											visibility = syn::parse_str::<syn::Visibility>(&vis_str)
295												.map_err(|e| {
296													Error::new_spanned(
297														value,
298														format!(
299															"invalid visibility: {e}. Supported: 'pub', 'pub(crate)', 'pub(super)', 'pub(self)' or empty for private",
300														),
301													)
302												})?;
303										},
304										_ => {
305											return Err(Error::new_spanned(
306												value,
307												"visibility value must be a string literal",
308											));
309										},
310									}
311								} else {
312									return Err(Error::new_spanned(
313										path,
314										format!(
315											"unknown dissolve attribute option '{}'; supported option: {}",
316											path.get_ident()
317												.map(|i| i.to_string())
318												.unwrap_or_default(),
319											Self::VISIBILITY_IDENT,
320										),
321									));
322								}
323							},
324							_ => {
325								return Err(Error::new_spanned(
326									nested_meta,
327									"dissolve container attribute must use name-value syntax: #[dissolve(visibility = \"...\")]",
328								));
329							},
330						}
331					}
332				},
333				_ => {
334					return Err(Error::new_spanned(
335						attr,
336						"dissolve attribute must use list syntax: #[dissolve(visibility = \"...\")]",
337					));
338				},
339			}
340		}
341
342		Ok(Self { visibility })
343	}
344}
345
346#[derive(Debug, Clone, PartialEq, Eq)]
347enum DissolvedOption {
348	Skip,
349	Rename(syn::Ident),
350}
351
352#[derive(Debug, Clone)]
353struct FieldInfo {
354	should_skip: bool,
355	renamed_to: Option<syn::Ident>,
356}
357
358impl DissolvedOption {
359	const IDENT: &str = "dissolved";
360
361	const SKIP_IDENT: &str = "skip";
362
363	const RENAME_IDENT: &str = "rename";
364
365	fn from_meta(meta: &Meta) -> Result<Self> {
366		let unknown_attribute_err = |path: &syn::Path| {
367			let path_str = path
368				.segments
369				.iter()
370				.map(|seg| seg.ident.to_string())
371				.collect::<Vec<_>>()
372				.join("::");
373
374			Error::new_spanned(
375				path,
376				format!(
377					"unknown dissolved attribute option '{}'; supported options: {}, {} = \"new_name\"",
378					Self::SKIP_IDENT,
379					Self::RENAME_IDENT,
380					path_str,
381				),
382			)
383		};
384
385		let opt = match meta {
386			Meta::Path(path) => {
387				if !path.is_ident(Self::SKIP_IDENT) {
388					return Err(unknown_attribute_err(path));
389				}
390
391				DissolvedOption::Skip
392			},
393			Meta::NameValue(MetaNameValue { path, value, .. }) => {
394				if !path.is_ident(Self::RENAME_IDENT) {
395					return Err(unknown_attribute_err(path));
396				}
397
398				match value {
399					Expr::Lit(ExprLit { lit: Lit::Str(lit_str), .. }) => {
400						syn::parse_str::<syn::Ident>(&lit_str.value())
401							.map(DissolvedOption::Rename)?
402					},
403					_ => {
404						return Err(Error::new_spanned(
405							value,
406							format!("{} value must be a string literal", Self::RENAME_IDENT),
407						));
408					},
409				}
410			},
411			Meta::List(_) => {
412				return Err(Error::new_spanned(
413					meta,
414					"nested lists are not supported in dissolved attributes",
415				));
416			},
417		};
418
419		Ok(opt)
420	}
421}
422
423impl FieldInfo {
424	fn new() -> Self {
425		Self { should_skip: false, renamed_to: None }
426	}
427}
428
429fn generate_dissolve_impl(input: &DeriveInput) -> Result<proc_macro2::TokenStream> {
430	let struct_name = &input.ident;
431	let generics = &input.generics;
432	let container_attrs = ContainerAttributes::from_derive_input(input)?;
433
434	let Data::Struct(data_struct) = &input.data else {
435		return Err(Error::new_spanned(
436			input,
437			"Dissolve can only be derived for structs",
438		));
439	};
440
441	match &data_struct.fields {
442		Fields::Named(fields) => {
443			generate_named_struct_impl(struct_name, generics, fields, &container_attrs)
444		},
445		Fields::Unnamed(fields) => {
446			generate_tuple_struct_impl(struct_name, generics, fields, &container_attrs)
447		},
448		Fields::Unit => Err(Error::new_spanned(
449			input,
450			"Dissolve cannot be derived for unit structs",
451		)),
452	}
453}
454
455fn generate_named_struct_impl(
456	struct_name: &syn::Ident,
457	generics: &syn::Generics,
458	fields: &syn::FieldsNamed,
459	container_attrs: &ContainerAttributes,
460) -> Result<proc_macro2::TokenStream> {
461	let included_fields: Vec<_> = fields
462		.named
463		.iter()
464		.map(|field| {
465			let info = get_field_info(field)?;
466			if info.should_skip {
467				Ok((None, info))
468			} else {
469				Ok((Some(field), info))
470			}
471		})
472		.filter_map(|res| match res {
473			Ok((Some(field), info)) => Some(Ok((field, info))),
474			Err(e) => Some(Err(e)),
475			_ => None,
476		})
477		.collect::<Result<_>>()?;
478
479	if included_fields.is_empty() {
480		return Err(Error::new_spanned(
481			struct_name,
482			"cannot create dissolved struct with no fields (all fields are skipped)",
483		));
484	}
485
486	let field_definitions = included_fields.iter().map(|(field, info)| {
487		// unwrap is safe because struct has named fields
488		let original_name = field.ident.as_ref().unwrap();
489		let ty = &field.ty;
490
491		let dissolved_field_name = match &info.renamed_to {
492			Some(new_name) => new_name,
493			None => original_name,
494		};
495
496		// Extract doc comments from the original field
497		let doc_attrs = field.attrs.iter().filter(|attr| attr.path().is_ident("doc"));
498
499		quote! {
500			#(#doc_attrs)*
501			pub #dissolved_field_name: #ty
502		}
503	});
504
505	let field_moves = included_fields.iter().map(|(field, info)| {
506		// unwrap is safe because struct has named fields
507		let original_name = field.ident.as_ref().unwrap();
508
509		let dissolved_field_name = match &info.renamed_to {
510			Some(new_name) => new_name,
511			None => original_name,
512		};
513
514		quote! { #dissolved_field_name: self.#original_name }
515	});
516
517	let dissolved_struct_name = format_ident!("{}Dissolved", struct_name);
518
519	// Split generics for use in different positions
520	let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
521
522	let dissolved_struct_doc = format!(
523		"Dissolved struct for [`{struct_name}`].\n\n\
524		This struct contains all non-skipped fields from the original struct. \
525		The visibility of this struct matches the visibility of the `dissolve` method. \
526		Fields may be renamed according to `#[dissolved(rename = \"...\")]` attributes.",
527	);
528
529	let visibility = &container_attrs.visibility;
530
531	Ok(quote! {
532		#[doc = #dissolved_struct_doc]
533		#visibility struct #dissolved_struct_name #impl_generics #where_clause {
534			#(#field_definitions),*
535		}
536
537		impl #impl_generics #struct_name #ty_generics #where_clause {
538			/// Dissolve this struct into its public-field equivalent.
539			///
540			/// This method consumes the original struct and returns a new struct where all included
541			/// fields are made public and optionally renamed.
542			#visibility fn dissolve(self) -> #dissolved_struct_name #ty_generics {
543				#dissolved_struct_name {
544					#(#field_moves),*
545				}
546			}
547		}
548	})
549}
550
551fn generate_tuple_struct_impl(
552	struct_name: &syn::Ident,
553	generics: &syn::Generics,
554	fields: &FieldsUnnamed,
555	container_attrs: &ContainerAttributes,
556) -> Result<proc_macro2::TokenStream> {
557	// For tuple structs, only `skip` is supported (`rename` does not make sense)
558	let included_fields: Vec<_> = fields
559		.unnamed
560		.iter()
561		.enumerate()
562		.filter_map(|(index, field)| {
563			match get_field_info(field) {
564				Ok(info) => {
565					if info.should_skip {
566						None
567					} else {
568						// Check if rename was attempted on tuple struct
569						if info.renamed_to.is_some() {
570							Some(Err(Error::new_spanned(
571								field,
572								format!(
573									"{} is unsupported for tuple struct fields, only {} is allowed",
574									DissolvedOption::RENAME_IDENT,
575									DissolvedOption::SKIP_IDENT,
576								),
577							)))
578						} else {
579							Some(Ok((index, field)))
580						}
581					}
582				},
583				Err(err) => Some(Err(err)),
584			}
585		})
586		.collect::<Result<_>>()?;
587
588	if included_fields.is_empty() {
589		return Err(Error::new_spanned(
590			struct_name,
591			"cannot create dissolved tuple with no fields (all fields are skipped)",
592		));
593	}
594
595	let tuple_types = included_fields.iter().map(|(_, field)| &field.ty);
596	let tuple_type = if included_fields.len() == 1 {
597		// Single element tuple needs trailing comma
598		let ty = &included_fields[0].1.ty;
599		quote! { (#ty,) }
600	} else {
601		quote! { (#(#tuple_types),*) }
602	};
603
604	let field_moves = included_fields.iter().map(|(original_index, _)| {
605		let index = Index::from(*original_index);
606		quote! { self.#index }
607	});
608
609	let tuple_construction = if included_fields.len() == 1 {
610		// Single element tuple needs trailing comma
611		quote! { (#(#field_moves,)*) }
612	} else {
613		quote! { (#(#field_moves),*) }
614	};
615
616	// Split generics for use in different positions
617	let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
618	let visibility = &container_attrs.visibility;
619
620	Ok(quote! {
621		impl #impl_generics #struct_name #ty_generics #where_clause {
622			/// Dissolve this tuple struct into a tuple of its included non-skipped fields.
623			#visibility fn dissolve(self) -> #tuple_type {
624				#tuple_construction
625			}
626		}
627	})
628}
629
630fn get_field_info(field: &Field) -> Result<FieldInfo> {
631	let mut field_info = FieldInfo::new();
632
633	for attr in field.attrs.iter().filter(|attr| attr.path().is_ident(DissolvedOption::IDENT)) {
634		match attr.meta.clone() {
635			Meta::List(_) => {
636				// Parse #[dissolved(skip)] or #[dissolved(rename = "new_name")]
637				let nested_metas = attr.parse_args_with(
638					syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
639				)?;
640
641				for nested_meta in nested_metas {
642					let option = DissolvedOption::from_meta(&nested_meta)?;
643					match option {
644						DissolvedOption::Skip => {
645							if field_info.renamed_to.is_some() {
646								return Err(Error::new_spanned(
647									attr,
648									format!(
649										"cannot use {} on skipped field",
650										DissolvedOption::RENAME_IDENT,
651									),
652								));
653							}
654
655							field_info.should_skip = true;
656						},
657						DissolvedOption::Rename(new_ident) => {
658							if field_info.should_skip {
659								return Err(Error::new_spanned(
660									attr,
661									format!(
662										"cannot use {} on skipped field",
663										DissolvedOption::RENAME_IDENT,
664									),
665								));
666							}
667
668							if field_info.renamed_to.is_some() {
669								return Err(Error::new_spanned(
670									attr,
671									format!(
672										"cannot specify multiple {} options on the same field",
673										DissolvedOption::RENAME_IDENT,
674									),
675								));
676							}
677
678							field_info.renamed_to = Some(new_ident);
679						},
680					}
681				}
682			},
683			Meta::Path(_) => {
684				return Err(Error::new_spanned(
685					attr,
686					format!(
687						"dissolved attribute requires options, use #[dissolved({})] or #[dissolved({} = \"new_name\")] instead",
688						DissolvedOption::SKIP_IDENT,
689						DissolvedOption::RENAME_IDENT,
690					),
691				));
692			},
693			Meta::NameValue(_) => {
694				return Err(Error::new_spanned(
695					attr,
696					format!(
697						"dissolved attribute should use list syntax: #[dissolved({} = \"new_name\")] instead of #[dissolved = ...]",
698						DissolvedOption::RENAME_IDENT,
699					),
700				));
701			},
702		}
703	}
704
705	Ok(field_info)
706}