eventcore-macros 0.6.0

Procedural macros for EventCore event sourcing library
Documentation
#![forbid(
    dead_code,
    invalid_value,
    overflowing_literals,
    unconditional_recursion,
    unreachable_pub,
    unused_allocation,
    unsafe_code
)]
#![deny(
    bad_style,
    clippy::allow_attributes,
    deprecated,
    meta_variable_misuse,
    non_ascii_idents,
    non_camel_case_types,
    non_snake_case,
    non_upper_case_globals,
    rust_2018_idioms,
    rust_2021_compatibility,
    trivial_casts,
    trivial_numeric_casts,
    unreachable_code,
    unused_assignments,
    unused_attributes,
    unused_extern_crates,
    unused_imports,
    unused_must_use,
    unused_mut,
    unused_parens,
    unused_qualifications,
    unused_results,
    unused_variables
)]

use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{
    Data, DeriveInput, Error, Field, Fields, Meta, Path, Type, parse_macro_input,
    punctuated::Punctuated,
};

/// Macro entry point that generates `CommandStreams` implementations for
/// structs whose `StreamId` fields are annotated with `#[stream]`.
#[proc_macro_derive(Command, attributes(stream))]
pub fn command(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    match expand_command(&input) {
        Ok(tokens) => tokens.into(),
        Err(error) => error.to_compile_error().into(),
    }
}

fn expand_command(input: &DeriveInput) -> syn::Result<TokenStream2> {
    let ident = &input.ident;
    let fields = extract_named_fields(input)?;
    let stream_exprs = collect_stream_fields(fields)?;

    if stream_exprs.is_empty() {
        return Err(Error::new_spanned(
            ident,
            "EventCore: #[derive(Command)] requires at least one #[stream] StreamId field; add #[stream] to your StreamId member",
        ));
    }

    Ok(quote! {
        impl ::eventcore::CommandStreams for #ident {
            fn stream_declarations(&self) -> ::eventcore::StreamDeclarations {
                ::eventcore::StreamDeclarations::try_from_streams(vec![
                    #( #stream_exprs ),*
                ])
                .expect("valid stream declarations generated by #[derive(Command)]")
            }
        }
    })
}

/// Ensures the derive target is a struct with named fields and returns them for
/// further validation.
fn extract_named_fields(input: &DeriveInput) -> syn::Result<&Punctuated<Field, syn::token::Comma>> {
    let Data::Struct(data_struct) = &input.data else {
        return Err(Error::new_spanned(
            &input.ident,
            "EventCore: #[derive(Command)] only supports structs with named fields",
        ));
    };

    let Fields::Named(fields) = &data_struct.fields else {
        return Err(Error::new_spanned(
            &input.ident,
            "EventCore: #[derive(Command)] only supports structs with named fields",
        ));
    };

    Ok(&fields.named)
}

/// Walks the struct fields, validating #[stream] usages and producing the
/// expressions used in the generated CommandStreams impl.
fn collect_stream_fields(
    fields: &Punctuated<Field, syn::token::Comma>,
) -> syn::Result<Vec<TokenStream2>> {
    let mut stream_exprs = Vec::new();

    for field in fields {
        if has_stream_marker(field)? {
            stream_exprs.push(stream_expression(field)?);
        }
    }

    Ok(stream_exprs)
}

/// Returns true when the field carries a valid #[stream] attribute.
fn has_stream_marker(field: &Field) -> syn::Result<bool> {
    let mut marked = false;

    for attr in &field.attrs {
        if attr.path().is_ident("stream") {
            if !matches!(&attr.meta, Meta::Path(_)) {
                return Err(Error::new_spanned(
                    attr,
                    "EventCore: #[stream] does not accept parameters",
                ));
            }

            marked = true;
        }
    }

    Ok(marked)
}

/// Produces the `self.field.clone()` expression for a validated stream field.
fn stream_expression(field: &Field) -> syn::Result<TokenStream2> {
    let Some(field_ident) = &field.ident else {
        return Err(Error::new_spanned(
            field,
            "EventCore: #[derive(Command)] only supports structs with named fields",
        ));
    };

    ensure_stream_id_type(field)?;

    Ok(quote! { self.#field_ident.clone() })
}

/// Confirms the field type resolves to StreamId (allowing qualified paths).
fn ensure_stream_id_type(field: &Field) -> syn::Result<()> {
    match &field.ty {
        Type::Path(type_path) if is_eventcore_stream_id(&type_path.path) => Ok(()),
        _ => Err(Error::new_spanned(
            field,
            "EventCore: #[stream] fields must have type eventcore::StreamId",
        )),
    }
}

fn is_eventcore_stream_id(path: &Path) -> bool {
    let Some(last) = path.segments.last() else {
        return false;
    };

    if last.ident != "StreamId" {
        return false;
    }

    path.segments
        .iter()
        .take(path.segments.len().saturating_sub(1))
        .all(|segment| {
            matches!(
                segment.ident.to_string().as_str(),
                "eventcore" | "crate" | "self" | "super"
            )
        })
}