1#![forbid(
2 dead_code,
3 invalid_value,
4 overflowing_literals,
5 unconditional_recursion,
6 unreachable_pub,
7 unused_allocation,
8 unsafe_code
9)]
10#![deny(
11 bad_style,
12 clippy::allow_attributes,
13 deprecated,
14 meta_variable_misuse,
15 non_ascii_idents,
16 non_camel_case_types,
17 non_snake_case,
18 non_upper_case_globals,
19 rust_2018_idioms,
20 rust_2021_compatibility,
21 trivial_casts,
22 trivial_numeric_casts,
23 unreachable_code,
24 unused_assignments,
25 unused_attributes,
26 unused_extern_crates,
27 unused_imports,
28 unused_must_use,
29 unused_mut,
30 unused_parens,
31 unused_qualifications,
32 unused_results,
33 unused_variables
34)]
35
36use proc_macro::TokenStream;
37use proc_macro2::TokenStream as TokenStream2;
38use quote::quote;
39use syn::{
40 Data, DeriveInput, Error, Field, Fields, Meta, Path, Type, parse_macro_input,
41 punctuated::Punctuated,
42};
43
44#[proc_macro_derive(Command, attributes(stream))]
47pub fn command(input: TokenStream) -> TokenStream {
48 let input = parse_macro_input!(input as DeriveInput);
49
50 match expand_command(&input) {
51 Ok(tokens) => tokens.into(),
52 Err(error) => error.to_compile_error().into(),
53 }
54}
55
56fn expand_command(input: &DeriveInput) -> syn::Result<TokenStream2> {
57 let ident = &input.ident;
58 let fields = extract_named_fields(input)?;
59 let stream_exprs = collect_stream_fields(fields)?;
60
61 if stream_exprs.is_empty() {
62 return Err(Error::new_spanned(
63 ident,
64 "EventCore: #[derive(Command)] requires at least one #[stream] StreamId field; add #[stream] to your StreamId member",
65 ));
66 }
67
68 Ok(quote! {
69 impl ::eventcore::CommandStreams for #ident {
70 fn stream_declarations(&self) -> ::eventcore::StreamDeclarations {
71 ::eventcore::StreamDeclarations::try_from_streams(vec![
72 #( #stream_exprs ),*
73 ])
74 .expect("valid stream declarations generated by #[derive(Command)]")
75 }
76 }
77 })
78}
79
80fn extract_named_fields(input: &DeriveInput) -> syn::Result<&Punctuated<Field, syn::token::Comma>> {
83 let Data::Struct(data_struct) = &input.data else {
84 return Err(Error::new_spanned(
85 &input.ident,
86 "EventCore: #[derive(Command)] only supports structs with named fields",
87 ));
88 };
89
90 let Fields::Named(fields) = &data_struct.fields else {
91 return Err(Error::new_spanned(
92 &input.ident,
93 "EventCore: #[derive(Command)] only supports structs with named fields",
94 ));
95 };
96
97 Ok(&fields.named)
98}
99
100fn collect_stream_fields(
103 fields: &Punctuated<Field, syn::token::Comma>,
104) -> syn::Result<Vec<TokenStream2>> {
105 let mut stream_exprs = Vec::new();
106
107 for field in fields {
108 if has_stream_marker(field)? {
109 stream_exprs.push(stream_expression(field)?);
110 }
111 }
112
113 Ok(stream_exprs)
114}
115
116fn has_stream_marker(field: &Field) -> syn::Result<bool> {
118 let mut marked = false;
119
120 for attr in &field.attrs {
121 if attr.path().is_ident("stream") {
122 if !matches!(&attr.meta, Meta::Path(_)) {
123 return Err(Error::new_spanned(
124 attr,
125 "EventCore: #[stream] does not accept parameters",
126 ));
127 }
128
129 marked = true;
130 }
131 }
132
133 Ok(marked)
134}
135
136fn stream_expression(field: &Field) -> syn::Result<TokenStream2> {
138 let Some(field_ident) = &field.ident else {
139 return Err(Error::new_spanned(
140 field,
141 "EventCore: #[derive(Command)] only supports structs with named fields",
142 ));
143 };
144
145 ensure_stream_id_type(field)?;
146
147 Ok(quote! { self.#field_ident.clone() })
148}
149
150fn ensure_stream_id_type(field: &Field) -> syn::Result<()> {
152 match &field.ty {
153 Type::Path(type_path) if is_eventcore_stream_id(&type_path.path) => Ok(()),
154 _ => Err(Error::new_spanned(
155 field,
156 "EventCore: #[stream] fields must have type eventcore::StreamId",
157 )),
158 }
159}
160
161fn is_eventcore_stream_id(path: &Path) -> bool {
162 let Some(last) = path.segments.last() else {
163 return false;
164 };
165
166 if last.ident != "StreamId" {
167 return false;
168 }
169
170 path.segments
171 .iter()
172 .take(path.segments.len().saturating_sub(1))
173 .all(|segment| {
174 matches!(
175 segment.ident.to_string().as_str(),
176 "eventcore" | "crate" | "self" | "super"
177 )
178 })
179}