statum-macros 0.8.4

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

use crate::{
    ItemTarget, StateModulePath, lookup_loaded_state_enum, lookup_loaded_state_enum_by_name,
};

use super::metadata::is_rust_analyzer;
use super::MachineInfo;

pub fn invalid_machine_target_error(item: &Item) -> TokenStream {
    let target = ItemTarget::from(item);
    let message = match target.name() {
        Some(name) => format!(
            "Error: #[machine] must be applied to a struct, but `{name}` is {} {}.\nFix: declare `struct {name}<State> {{ ... }}` and apply `#[machine]` to that struct.",
            target.article(),
            target.kind(),
        ),
        None => format!(
            "Error: #[machine] must be applied to a struct, but this item is {} {}.\nFix: apply `#[machine]` to a struct like `struct Machine<State> {{ ... }}`.",
            target.article(),
            target.kind(),
        ),
    };
    syn::Error::new(target.span(), message).to_compile_error()
}

pub fn validate_machine_struct(item: &ItemStruct, machine_info: &MachineInfo) -> Option<TokenStream> {
    let machine_name = machine_info.name.clone();

    for field in &item.fields {
        let Some(attr_name) = cfg_like_attr_name(&field.attrs) else {
            continue;
        };
        let field_name = field
            .ident
            .as_ref()
            .map(ToString::to_string)
            .unwrap_or_else(|| "field".to_string());
        let message = format!(
            "Error: #[machine] struct `{machine_name}` field `{field_name}` uses `#[{attr_name}]`, but Statum does not support conditionally compiled machine fields.\nFix: move the cfg gate to the whole `#[machine]` item or split cfg-specific field sets into separate machines."
        );
        return Some(syn::Error::new_spanned(field, message).to_compile_error());
    }

    let Some(first_generic_param) = item.generics.params.first() else {
        return Some(
            syn::Error::new_spanned(
                &item.ident,
                format!(
                    "Error: machine `{machine_name}` is missing its `#[state]` generic.\nFix: declare `{machine_name}<State>` where `State` is the `#[state]` enum in this module."
                ),
            )
            .to_compile_error(),
        );
    };

    let state_path: StateModulePath = machine_info.module_path.clone();
    let matching_state_enum = machine_info
        .state_generic_name
        .as_deref()
        .and_then(|state_name| lookup_loaded_state_enum_by_name(&state_path, state_name).ok())
        .or_else(|| lookup_loaded_state_enum(&state_path).ok());

    let first_generic_param_display = first_generic_param.to_token_stream().to_string();
    let syn::GenericParam::Type(_) = first_generic_param else {
        return Some(
            syn::Error::new_spanned(
                first_generic_param,
                format!(
                    "Error: machine `{machine_name}` uses `{first_generic_param_display}` as its first generic, but Statum needs a type parameter naming the `#[state]` enum.\nFix: declare `{machine_name}<State>` where `State` is your `#[state]` enum."
                ),
            )
            .to_compile_error(),
        );
    };
    let matching_state_enum = match matching_state_enum {
        Some(enum_info) => enum_info,
        None => match machine_info.get_matching_state_enum() {
            Ok(enum_info) => enum_info,
            Err(err) => return Some(err),
        },
    };

    let machine_derives = machine_info.derives.clone();
    let state_derives = matching_state_enum.derives.clone();
    let state_name = matching_state_enum.name.clone();

    let missing_derives: Vec<String> = machine_derives
        .iter()
        .filter(|derive| !state_derives.contains(derive))
        .cloned()
        .collect();

    if !missing_derives.is_empty() && !is_rust_analyzer() {
        let missing_list = missing_derives.join(", ");
        let message = format!(
            "Error: machine `{machine_name}` derives `{missing_list}`, but `#[state]` enum `{state_name}` does not.\nFix: add `#[derive({missing_list})]` to `{state_name}` so the generated state markers and `{machine_name}` stay compatible.",
        );
        return Some(syn::Error::new_spanned(&item.ident, message).to_compile_error());
    }

    if first_generic_param_display != state_name {
        let generics_display = item.generics.to_token_stream().to_string();
        let message = format!(
            "Error: machine `{machine_name}` uses `{first_generic_param_display}` as its state generic, but the `#[state]` enum in this module is `{state_name}`.\nFix: declare `{machine_name}<{state_name}>`.\nFound: `struct {machine_name}{generics_display} {{ ... }}`."
        );
        return Some(syn::Error::new_spanned(first_generic_param, message).to_compile_error());
    }

    None
}

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
        }
    })
}