eventcore_macros/
lib.rs

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/// Macro entry point that generates `CommandStreams` implementations for
45/// structs whose `StreamId` fields are annotated with `#[stream]`.
46#[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
80/// Ensures the derive target is a struct with named fields and returns them for
81/// further validation.
82fn 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
100/// Walks the struct fields, validating #[stream] usages and producing the
101/// expressions used in the generated CommandStreams impl.
102fn 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
116/// Returns true when the field carries a valid #[stream] attribute.
117fn 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
136/// Produces the `self.field.clone()` expression for a validated stream field.
137fn 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
150/// Confirms the field type resolves to StreamId (allowing qualified paths).
151fn 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}