statum-macros 0.8.10

Proc macros for representing legal workflow and protocol states explicitly in Rust
Documentation
use proc_macro2::TokenStream;
use syn::{Fields, Item, ItemEnum};

use crate::diagnostics::{DiagnosticMessage, compact_display, item_signature};
use crate::source::ItemTarget;

pub fn invalid_state_target_error(item: &Item) -> TokenStream {
    let target = ItemTarget::from(item);
    let expected_name = target.name().unwrap_or("WorkflowState");
    let message = DiagnosticMessage::new("#[state] must be applied to an enum.")
        .found(item_signature(item))
        .expected(format!(
            "`enum {expected_name} {{ Draft, InReview(InReviewData) }}`"
        ))
        .fix(match target.name() {
            Some(name) => format!(
                "change `{name}` from {} {} into a `#[state]` enum, or remove `#[state]`.",
                target.article(),
                target.kind()
            ),
            None => "apply `#[state]` to an enum item instead.".to_string(),
        });
    syn::Error::new(target.span(), message.render()).to_compile_error()
}

pub fn validate_state_enum(item: &ItemEnum) -> Option<TokenStream> {
    validate_state_enum_shape(item)
        .err()
        .map(|err| err.to_compile_error())
}

pub(super) fn validate_state_enum_shape(item: &ItemEnum) -> syn::Result<()> {
    let enum_name = item.ident.to_string();

    if !item.generics.params.is_empty() {
        let generics_display = compact_display(&item.generics);
        return Err(syn::Error::new_spanned(
            &item.generics,
            DiagnosticMessage::new(format!(
                "`#[state]` enum `{enum_name}` cannot declare generics."
            ))
            .found(format!("`enum {enum_name}{generics_display} {{ ... }}`"))
            .expected(format!("`enum {enum_name} {{ Draft, Review(ReviewData) }}`"))
            .fix(format!(
                "keep `{enum_name}` non-generic and move the generic data into a payload type such as `ReviewData<T>`."
            ))
            .render(),
        ));
    }

    if item.variants.is_empty() {
        return Err(syn::Error::new_spanned(
            &item.ident,
            DiagnosticMessage::new(format!(
                "`#[state]` enum `{enum_name}` must declare at least one variant."
            ))
            .found(format!("`enum {enum_name} {{}}`"))
            .expected(format!(
                "`enum {enum_name} {{ Draft, InReview(InReviewData) }}`"
            ))
            .fix("add at least one unit state or single-payload state variant.")
            .render(),
        ));
    }

    for variant in &item.variants {
        if let Some(attr_name) = cfg_like_attr_name(&variant.attrs) {
            let variant_name = variant.ident.to_string();
            return Err(syn::Error::new_spanned(
                variant,
                DiagnosticMessage::new(format!(
                    "`#[state]` enum `{enum_name}` variant `{variant_name}` uses `#[{attr_name}]`, but Statum does not support conditionally compiled state variants."
                ))
                .found(format!("`{}`", compact_display(variant)))
                .expected(format!("an unconditional `{variant_name}` variant inside `{enum_name}`"))
                .fix("move the cfg gate to the whole `#[state]` enum or split cfg-specific workflows into separate modules.")
                .render(),
            ));
        }

        for field in variant.fields.iter() {
            if let Some(attr_name) = cfg_like_attr_name(&field.attrs) {
                let variant_name = variant.ident.to_string();
                let field_name = field
                    .ident
                    .as_ref()
                    .map(ToString::to_string)
                    .unwrap_or_else(|| "payload field".to_string());
                return Err(syn::Error::new_spanned(
                    field,
                    DiagnosticMessage::new(format!(
                        "`#[state]` enum `{enum_name}` variant `{variant_name}` field `{field_name}` uses `#[{attr_name}]`, but Statum does not support conditionally compiled state payload fields."
                    ))
                    .found(format!("`{}`", compact_display(field)))
                    .expected(format!(
                        "an unconditional payload field for `{variant_name}`"
                    ))
                    .fix("move the cfg gate to the whole variant or wrap the cfg-specific payload shape behind a separate type.")
                    .render(),
                ));
            }
        }

        match &variant.fields {
            Fields::Unit => {}
            Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {}
            Fields::Unnamed(fields) => {
                let variant_name = variant.ident.to_string();
                let field_count = fields.unnamed.len();
                return Err(syn::Error::new_spanned(
                    variant,
                    DiagnosticMessage::new(format!(
                        "`#[state]` enum `{enum_name}` variant `{variant_name}` carries {field_count} fields, but Statum supports at most one payload type per state."
                    ))
                    .found(format!("`{}`", compact_display(variant)))
                    .expected(format!("`{variant_name}({variant_name}Data)`"))
                    .fix(format!(
                        "wrap the current fields in a payload type like `struct {variant_name}Data {{ ... }}` and use `enum {enum_name} {{ {variant_name}({variant_name}Data) }}`."
                    ))
                    .render(),
                ));
            }
            Fields::Named(fields) if fields.named.is_empty() => {
                let variant_name = variant.ident.to_string();
                return Err(syn::Error::new_spanned(
                    variant,
                    DiagnosticMessage::new(format!(
                        "`#[state]` enum `{enum_name}` variant `{variant_name}` uses empty named fields."
                    ))
                    .found(format!("`{}`", compact_display(variant)))
                    .expected(format!("`{variant_name}` or `{variant_name} {{ field: Type }}`"))
                    .fix(format!(
                        "use `{variant_name}` for a unit state or add at least one named payload field."
                    ))
                    .render(),
                ));
            }
            Fields::Named(_) => {}
        }
    }

    Ok(())
}

fn cfg_like_attr_name(attrs: &[syn::Attribute]) -> Option<&'static str> {
    attrs.iter().find_map(|attr| {
        if attr.path().is_ident("cfg") {
            Some("cfg")
        } else if attr.path().is_ident("cfg_attr") {
            Some("cfg_attr")
        } else {
            None
        }
    })
}