statum-macros 0.8.8

Proc macros for representing legal workflow and protocol states explicitly in Rust
Documentation
use quote::ToTokens;
use syn::{Attribute, Expr, ExprLit, Lit, LitStr, Type};

use crate::diagnostics::{DiagnosticMessage, compact_display, error_at, error_spanned};

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PresentationAttr {
    pub label: Option<String>,
    pub description: Option<String>,
    pub metadata: Option<String>,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PresentationTypesAttr {
    pub machine: Option<String>,
    pub state: Option<String>,
    pub transition: Option<String>,
}

impl PresentationTypesAttr {
    pub fn parse_machine_type(&self) -> syn::Result<Option<Type>> {
        parse_optional_type(self.machine.as_deref())
    }

    pub fn parse_state_type(&self) -> syn::Result<Option<Type>> {
        parse_optional_type(self.state.as_deref())
    }

    pub fn parse_transition_type(&self) -> syn::Result<Option<Type>> {
        parse_optional_type(self.transition.as_deref())
    }
}

pub fn parse_present_attrs(attrs: &[Attribute]) -> syn::Result<Option<PresentationAttr>> {
    let mut presentation = PresentationAttr::default();
    let mut found = false;

    for attr in attrs.iter().filter(|attr| attr.path().is_ident("present")) {
        found = true;
        if !matches!(attr.meta, syn::Meta::List(_)) {
            let message = DiagnosticMessage::new("`#[present(...)]` requires parentheses.")
                .found(format!("`#[{}]`", compact_display(&attr.meta)))
                .expected("`#[present(label = \"...\", description = \"...\")]`")
                .fix("write `#[present(...)]` with key/value pairs inside the parentheses.".to_string());
            return Err(error_spanned(attr, &message));
        }
        attr.parse_nested_meta(|meta| {
            let path = meta.path.clone();
            let Some(ident) = path.get_ident() else {
                let message = DiagnosticMessage::new(
                    "`#[present(...)]` keys must be simple identifiers.",
                )
                .found(format!("`{}`", compact_display(&path)))
                .expected("`label = \"...\"`, `description = \"...\"`, or `metadata = Expr`")
                .fix("write `#[present(label = \"...\", description = \"...\")]`.");
                return Err(error_spanned(&path, &message));
            };

            let value = meta.value()?;
            let value_span = value.span();
            let expr: Expr = value.parse()?;
            match ident.to_string().as_str() {
                "label" => {
                    let lit = expect_string_literal(&expr, ident, value_span)?;
                    assign_unique_string_slot(
                        &mut presentation.label,
                        ident,
                        lit.value(),
                        format!("\"{}\"", lit.value()),
                        "present",
                    )?;
                }
                "description" => {
                    let lit = expect_string_literal(&expr, ident, value_span)?;
                    assign_unique_string_slot(
                        &mut presentation.description,
                        ident,
                        lit.value(),
                        format!("\"{}\"", lit.value()),
                        "present",
                    )?;
                }
                "metadata" => {
                    assign_unique_string_slot(
                        &mut presentation.metadata,
                        ident,
                        expr.to_token_stream().to_string(),
                        compact_display(&expr),
                        "present",
                    )?;
                }
                _ => {
                    let message = DiagnosticMessage::new(format!(
                        "unknown `#[present(...)]` key `{ident}`."
                    ))
                    .found(format!("`{ident} = {}`", compact_display(&expr)))
                    .expected("`label = \"...\"`, `description = \"...\"`, or `metadata = Expr`")
                    .fix("replace that key or remove it.");
                    return Err(error_spanned(ident, &message));
                }
            }
            Ok(())
        })?;
    }

    if found {
        Ok(Some(presentation))
    } else {
        Ok(None)
    }
}

pub fn parse_presentation_types_attr(
    attrs: &[Attribute],
) -> syn::Result<Option<PresentationTypesAttr>> {
    let mut presentation_types = PresentationTypesAttr::default();
    let mut found = false;

    for attr in attrs
        .iter()
        .filter(|attr| attr.path().is_ident("presentation_types"))
    {
        found = true;
        if !matches!(attr.meta, syn::Meta::List(_)) {
            let message = DiagnosticMessage::new(
                "`#[presentation_types(...)]` requires parentheses.",
            )
            .found(format!("`#[{}]`", compact_display(&attr.meta)))
            .expected(
                "`#[presentation_types(machine = MachineMeta, state = StateMeta, transition = TransitionMeta)]`",
            )
            .fix("write `#[presentation_types(...)]` with key/type pairs inside the parentheses.".to_string());
            return Err(error_spanned(attr, &message));
        }
        attr.parse_nested_meta(|meta| {
            let path = meta.path.clone();
            let Some(ident) = path.get_ident() else {
                let message = DiagnosticMessage::new(
                    "`#[presentation_types(...)]` keys must be simple identifiers.",
                )
                .found(format!("`{}`", compact_display(&path)))
                .expected("`machine = MachineMeta`, `state = StateMeta`, or `transition = TransitionMeta`")
                .fix("write `#[presentation_types(state = StateMeta)]` with plain identifier keys.");
                return Err(error_spanned(&path, &message));
            };

            let value = meta.value()?;
            let ty: Type = value.parse()?;
            let ty_string = ty.to_token_stream().to_string();

            match ident.to_string().as_str() {
                "machine" => {
                    assign_unique_string_slot(
                        &mut presentation_types.machine,
                        ident,
                        ty_string.clone(),
                        ty_string,
                        "presentation_types",
                    )?;
                }
                "state" => {
                    assign_unique_string_slot(
                        &mut presentation_types.state,
                        ident,
                        ty_string.clone(),
                        ty_string,
                        "presentation_types",
                    )?;
                }
                "transition" => {
                    assign_unique_string_slot(
                        &mut presentation_types.transition,
                        ident,
                        ty_string.clone(),
                        ty_string,
                        "presentation_types",
                    )?;
                }
                _ => {
                    let message = DiagnosticMessage::new(format!(
                        "unknown `#[presentation_types(...)]` key `{ident}`."
                    ))
                    .found(format!("`{ident} = {ty_string}`"))
                    .expected(
                        "`machine = MachineMeta`, `state = StateMeta`, or `transition = TransitionMeta`",
                    )
                    .fix("replace that key or remove it.");
                    return Err(error_spanned(ident, &message));
                }
            }

            Ok(())
        })?;
    }

    if found {
        Ok(Some(presentation_types))
    } else {
        Ok(None)
    }
}

pub fn strip_present_attrs(attrs: &[Attribute]) -> Vec<Attribute> {
    attrs.iter()
        .filter(|attr| !attr.path().is_ident("present"))
        .cloned()
        .collect()
}

fn expect_string_literal(
    expr: &Expr,
    ident: &syn::Ident,
    span: proc_macro2::Span,
) -> Result<LitStr, syn::Error> {
    let Expr::Lit(ExprLit {
        lit: Lit::Str(lit), ..
    }) = expr
    else {
        let message = DiagnosticMessage::new(format!(
            "`#[present({ident} = ...)]` expects a string literal."
        ))
        .found(format!("`{ident} = {}`", compact_display(expr)))
        .expected(format!("`{ident} = \"...\"`"))
        .fix(format!("write `#[present({ident} = \"...\")]`."));
        return Err(error_at(span, &message));
    };

    Ok(lit.clone())
}

fn assign_unique_string_slot(
    slot: &mut Option<String>,
    ident: &syn::Ident,
    stored_value: String,
    found_display: String,
    attr_name: &str,
) -> Result<(), syn::Error> {
    if slot.is_some() {
        let field_label = match attr_name {
            "present" => "presentation field".to_string(),
            _ => format!("{attr_name} field"),
        };
        let message = DiagnosticMessage::new(format!(
            "duplicate `#[{attr_name}(...)]` key `{ident}`."
        ))
        .found(format!("`{ident} = {found_display}`"))
        .expected(format!("one `{ident}` {field_label} entry"))
        .fix(format!(
            "specify `{ident}` at most once inside `#[{attr_name}(...)]`."
        ));
        return Err(error_spanned(ident, &message));
    }

    *slot = Some(stored_value);
    Ok(())
}

fn parse_optional_type(value: Option<&str>) -> syn::Result<Option<Type>> {
    value
        .map(syn::parse_str::<Type>)
        .transpose()
}