statum-macros 0.9.0

Proc macros for representing legal workflow and protocol states explicitly in Rust
Documentation
use super::contract::{describe_invalid_return_type, describe_mismatched_introspect_return};
use super::parse::{TransitionFn, TransitionIntrospectAttr};
use crate::diagnostics::{DiagnosticMessage, compact_display};
use crate::{EnumInfo, MachineInfo, format_loaded_machine_candidates};
use proc_macro2::{Span, TokenStream};
use syn::{LitStr, Type};

pub(crate) struct MissingTransitionMachineContext {
    pub(crate) suggested_machine_name: String,
    pub(crate) ordering_line: Option<String>,
    pub(crate) elsewhere_line: String,
    pub(crate) available_line: String,
    pub(crate) missing_attr_line: Option<String>,
}

pub fn missing_transition_machine_error(
    machine_name: &str,
    module_path: &str,
    context: &MissingTransitionMachineContext,
    span: Span,
) -> TokenStream {
    let ordering_line = context
        .ordering_line
        .as_ref()
        .map(|line| format!("{line}\n"))
        .unwrap_or_default();
    let message = format!(
        "Error: `#[transition]` could not resolve machine `{machine_name}` in module `{module_path}`.\nFix: apply `#[transition]` to an impl for the machine type generated by `#[machine]` in this module and declare that machine before the transition impl.\nStatum only resolves `#[machine]` items that have already expanded before this `#[transition]` impl. Include-generated transition fragments are only supported when the machine name is unique among the currently loaded machines in this crate.\n{ordering_line}{}\n{elsewhere_line}\n{available_line}\nCorrect shape: `#[transition] impl {suggested_machine_name}<CurrentState> {{ ... }}` where `{suggested_machine_name}` is declared with `#[machine]` in `{module_path}`.",
        context.missing_attr_line.clone().unwrap_or_else(|| {
            "No plain struct with that name was found in this module either.".to_string()
        }),
        elsewhere_line = context.elsewhere_line.as_str(),
        available_line = context.available_line.as_str(),
        suggested_machine_name = context.suggested_machine_name.as_str(),
    );
    compile_error_at(span, &message)
}

pub fn ambiguous_transition_machine_error(
    machine_name: &str,
    module_path: &str,
    candidates: &[MachineInfo],
    span: Span,
) -> TokenStream {
    let candidate_line = format_loaded_machine_candidates(candidates);
    let message = format!(
        "Error: resolved `#[machine]` named `{machine_name}` was ambiguous in module `{module_path}`.\nLoaded `#[machine]` candidates: {candidate_line}.\nHelp: keep one active `#[machine]` with that name in the module, or move conflicting machines into distinct modules."
    );
    compile_error_at(span, &message)
}

pub fn ambiguous_transition_machine_fallback_error(
    machine_name: &str,
    module_path: &str,
    candidates: &[MachineInfo],
    span: Span,
) -> TokenStream {
    let candidate_line = format_loaded_machine_candidates(candidates);
    let message = format!(
        "Error: include-generated `#[transition]` impl for `{machine_name}` could not resolve a unique `#[machine]` item in module `{module_path}`.\nFix: keep the machine name unique within the current crate for include-generated transition fragments, or move the transition impl next to its machine definition.\nLoaded `#[machine]` candidates: {candidate_line}."
    );
    compile_error_at(span, &message)
}

pub(super) fn invalid_transition_state_error(
    state_span: Span,
    machine_name: &str,
    state_name: &str,
    state_enum_info: &EnumInfo,
    role: &str,
) -> TokenStream {
    let valid_states = state_enum_info
        .variants
        .iter()
        .map(|variant| variant.name.clone())
        .collect::<Vec<_>>()
        .join(", ");
    let target_type_display = format!("{machine_name}<{state_name}>");
    let state_enum_name = &state_enum_info.name;
    let message = format!(
        "Error: {role} state `{state_name}` in `#[transition]` target `{target_type_display}` is not a variant of `#[state]` enum `{state_enum_name}`.\nValid states for `{machine_name}` are: {valid_states}.\nHelp: change the impl target to `impl {machine_name}<ValidState>` using one of those variants."
    );
    compile_error_at(state_span, &message)
}

pub(super) fn invalid_transition_method_state_error(
    func: &TransitionFn,
    machine_name: &str,
    return_state: &str,
    state_enum_info: &EnumInfo,
) -> TokenStream {
    let valid_states = state_enum_info
        .variants
        .iter()
        .map(|variant| variant.name.clone())
        .collect::<Vec<_>>()
        .join(", ");
    let state_enum_name = &state_enum_info.name;
    let func_name = &func.name;
    let message = format!(
        "Error: transition method `{func_name}` returns state `{return_state}`, but `{return_state}` is not a variant of `#[state]` enum `{state_enum_name}`.\nValid next states for `{machine_name}` are: {valid_states}.\nHelp: return `{machine_name}<ValidState>` using one of those variants, or call `self.transition()` / `self.transition_with(...)`."
    );
    compile_error_at(func.return_type_span.unwrap_or(func.span), &message)
}

pub(super) fn invalid_return_type_error(
    func: &TransitionFn,
    target_type: &Type,
    reason: &str,
) -> TokenStream {
    let func_name = &func.name;
    let facts = describe_invalid_return_type(func, target_type);

    let mut message = DiagnosticMessage::new(format!(
        "transition method `{}<{}>::{func_name}` returns an unsupported type.",
        func.machine_name, func.source_state,
    ))
    .found(format!(
        "`fn {func_name}(self) -> {}`",
        facts.written_return_type
    ))
    .expected(facts.expected_signature)
    .reason(reason.to_string())
    .fix(facts.fix);

    if let Some(primary_branch) = facts.primary_branch {
        message = message.section("Primary branch", format!("`{primary_branch}`"));
    }
    if !facts.observed_machine_branches.is_empty() {
        message = message.section(
            "Observed machine branches",
            facts
                .observed_machine_branches
                .iter()
                .map(|branch| format!("`{branch}`"))
                .collect::<Vec<_>>()
                .join(", "),
        );
    }

    let uses_strict_resolution =
        crate::strict_introspection_enabled() || func.introspection.is_some();
    let note = if uses_strict_resolution {
        "Supported strict introspection shapes are direct machine paths and supported `::core::option::Option<...>`, `::core::result::Result<..., E>`, and `::statum::Branch<..., ...>` wrappers around direct machine paths.\nSource-backed aliases may be expanded only to suggest an explicit `#[introspect(return = ...)]`; they are not accepted as authoritative transition contracts in strict mode. Imported aliases, macro-generated aliases, include-generated aliases, ambiguous aliases, and foreign machine paths are rejected."
    } else {
        "Supported wrappers are `::core::option::Option<...>`, `::core::result::Result<..., E>`, and `::statum::Branch<..., ...>`, plus ordinary source-declared type aliases that expand to those shapes.\nImported aliases, macro-generated aliases, include-generated aliases, ambiguous aliases, and foreign machine paths are still rejected because transition introspection only follows source-backed type aliases it can resolve in-module."
    };
    message = message.note(note);
    if let Some(strict_help) = facts.strict_help {
        message = message.help(strict_help);
    }

    compile_error_at(
        func.return_type_span.unwrap_or(func.span),
        &message.render(),
    )
}

pub(super) fn invalid_introspect_return_error(
    introspection: &TransitionIntrospectAttr,
    func: &TransitionFn,
    reason: &str,
) -> TokenStream {
    let message = DiagnosticMessage::new(format!(
        "`#[introspect(return = ...)]` on transition `{}<{}>::{}` is invalid.",
        func.machine_name, func.source_state, func.name,
    ))
    .found(format!("`#[introspect(return = {})]`", compact_display(&introspection.return_type)))
    .expected("a direct machine path or a supported `Option`, `Result`, or `statum::Branch` wrapper around that machine path")
    .reason(reason.to_string())
    .fix("rewrite `return = ...` so it names the legal transition targets directly.".to_string());
    compile_error_at(introspection.span, &message.render())
}

pub(super) fn mismatched_introspect_return_error(
    introspection: &TransitionIntrospectAttr,
    func: &TransitionFn,
    actual_return_type: &Type,
    target_type: &Type,
) -> TokenStream {
    let facts =
        describe_mismatched_introspect_return(introspection, func, actual_return_type, target_type);
    let actual_return = compact_display(actual_return_type);
    let mut message = DiagnosticMessage::new(format!(
        "`#[introspect(return = ...)]` on transition `{}<{}>::{}` does not match the directly readable written return type.",
        func.machine_name,
        func.source_state,
        func.name,
    ))
    .found(format!(
        "`#[introspect(return = {})]` with method return `{actual_return}`",
        compact_display(&introspection.return_type)
    ))
    .expected(facts.expected)
    .fix(facts.fix);
    if let Some(primary_branch) = facts.written_primary_branch {
        message = message.section("Written primary branch", format!("`{primary_branch}`"));
    }
    if let Some(primary_branch) = facts.annotated_primary_branch {
        message = message.section("Annotated primary branch", format!("`{primary_branch}`"));
    }
    if !facts.observed_machine_branches.is_empty() {
        message = message.section(
            "Observed machine branches",
            facts
                .observed_machine_branches
                .iter()
                .map(|branch| format!("`{branch}`"))
                .collect::<Vec<_>>()
                .join(", "),
        );
    }
    compile_error_at(introspection.span, &message.render())
}

pub(super) fn machine_return_signature(machine_name: &str) -> String {
    format!("{machine_name}<NextState>")
}

pub(super) fn compile_error_at(span: Span, message: &str) -> TokenStream {
    let message = LitStr::new(message, span);
    quote::quote_spanned! { span =>
        compile_error!(#message);
    }
}