Skip to main content

eventcore_macros/
lib.rs

1//! Procedural macros for the EventCore event sourcing library.
2//!
3//! This crate provides the `#[derive(Command)]` macro, which generates a
4//! `CommandStreams` trait implementation for a command struct. The generated
5//! implementation's `stream_declarations()` method returns every `StreamId`
6//! field annotated with `#[stream]`, declaring the event streams that the
7//! command reads and writes within a single atomic consistency boundary.
8//!
9//! # Usage
10//!
11//! Annotate a command struct with `#[derive(Command)]` and mark each
12//! `StreamId` field that participates in the consistency boundary with
13//! `#[stream]`:
14//!
15//! ```no_run
16//! use eventcore::{Command, StreamId};
17//!
18//! #[derive(Command)]
19//! pub struct TransferFunds {
20//!     #[stream]
21//!     pub source_account: StreamId,
22//!     #[stream]
23//!     pub destination_account: StreamId,
24//!     pub amount: u64,
25//! }
26//! ```
27//!
28//! # Requirements
29//!
30//! - The derive target must be a struct with named fields (tuple structs are not
31//!   supported).
32//! - At least one field must carry the `#[stream]` attribute.
33//! - Every `#[stream]`-annotated field must have type `StreamId` (or
34//!   `eventcore::StreamId`).
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/// Entry point for `#[derive(Command)]`.
45///
46/// Generates a `CommandStreams` implementation for the annotated struct. Every
47/// `StreamId` field marked with `#[stream]` is
48/// included in the generated `stream_declarations()` return value, establishing
49/// the command's atomic consistency boundary.
50///
51/// See the [crate-level documentation](self) for requirements and a usage
52/// example.
53#[proc_macro_derive(Command, attributes(stream))]
54pub fn command(input: TokenStream) -> TokenStream {
55    let input = parse_macro_input!(input as DeriveInput);
56
57    match expand_command(&input) {
58        Ok(tokens) => tokens.into(),
59        Err(error) => error.to_compile_error().into(),
60    }
61}
62
63fn expand_command(input: &DeriveInput) -> syn::Result<TokenStream2> {
64    let ident = &input.ident;
65    let fields = extract_named_fields(input)?;
66    let stream_exprs = collect_stream_fields(fields)?;
67
68    if stream_exprs.is_empty() {
69        return Err(Error::new_spanned(
70            ident,
71            "EventCore: #[derive(Command)] requires at least one #[stream] StreamId field; add #[stream] to your StreamId member",
72        ));
73    }
74
75    Ok(quote! {
76        impl ::eventcore::CommandStreams for #ident {
77            fn stream_declarations(&self) -> ::eventcore::StreamDeclarations {
78                ::eventcore::StreamDeclarations::try_from_streams(vec![
79                    #( #stream_exprs ),*
80                ])
81                .expect("valid stream declarations generated by #[derive(Command)]")
82            }
83        }
84    })
85}
86
87/// Ensures the derive target is a struct with named fields and returns them for
88/// further validation.
89fn extract_named_fields(input: &DeriveInput) -> syn::Result<&Punctuated<Field, syn::token::Comma>> {
90    let Data::Struct(data_struct) = &input.data else {
91        return Err(Error::new_spanned(
92            &input.ident,
93            "EventCore: #[derive(Command)] can only be applied to structs, not enums or unions",
94        ));
95    };
96
97    let Fields::Named(fields) = &data_struct.fields else {
98        return Err(Error::new_spanned(
99            &input.ident,
100            "EventCore: #[derive(Command)] requires named fields; tuple structs are not supported",
101        ));
102    };
103
104    Ok(&fields.named)
105}
106
107/// Walks the struct fields, validating #[stream] usages and producing the
108/// expressions used in the generated CommandStreams impl.
109fn collect_stream_fields(
110    fields: &Punctuated<Field, syn::token::Comma>,
111) -> syn::Result<Vec<TokenStream2>> {
112    let mut stream_exprs = Vec::new();
113
114    for field in fields {
115        if has_stream_marker(field)? {
116            stream_exprs.push(stream_expression(field)?);
117        }
118    }
119
120    Ok(stream_exprs)
121}
122
123/// Returns true when the field carries a valid #[stream] attribute.
124fn has_stream_marker(field: &Field) -> syn::Result<bool> {
125    let mut marked = false;
126
127    for attr in &field.attrs {
128        if attr.path().is_ident("stream") {
129            if !matches!(&attr.meta, Meta::Path(_)) {
130                return Err(Error::new_spanned(
131                    attr,
132                    "EventCore: #[stream] does not accept parameters",
133                ));
134            }
135
136            marked = true;
137        }
138    }
139
140    Ok(marked)
141}
142
143/// Produces the `self.field.clone()` expression for a validated stream field.
144fn stream_expression(field: &Field) -> syn::Result<TokenStream2> {
145    let Some(field_ident) = &field.ident else {
146        return Err(Error::new_spanned(
147            field,
148            "EventCore: #[derive(Command)] requires named fields",
149        ));
150    };
151
152    ensure_stream_id_type(field)?;
153
154    Ok(quote! { self.#field_ident.clone() })
155}
156
157/// Confirms the field type resolves to StreamId (allowing qualified paths).
158fn ensure_stream_id_type(field: &Field) -> syn::Result<()> {
159    match &field.ty {
160        Type::Path(type_path) if is_eventcore_stream_id(&type_path.path) => Ok(()),
161        _ => Err(Error::new_spanned(
162            field,
163            "EventCore: #[stream] fields must have type eventcore::StreamId",
164        )),
165    }
166}
167
168fn is_eventcore_stream_id(path: &Path) -> bool {
169    let Some(last) = path.segments.last() else {
170        return false;
171    };
172
173    if last.ident != "StreamId" {
174        return false;
175    }
176
177    path.segments
178        .iter()
179        .take(path.segments.len().saturating_sub(1))
180        .all(|segment| {
181            matches!(
182                segment.ident.to_string().as_str(),
183                "eventcore" | "crate" | "self" | "super"
184            )
185        })
186}