statum-macros 0.8.6

Proc macros for representing legal workflow and protocol states explicitly in Rust
Documentation
use super::parse::{TransitionFn, TransitionIntrospectAttr};
use super::resolve::{
    AliasResolutionContext, collect_machine_and_states_strict, expand_source_type_alias,
    extract_generic_type_refs, machine_segment_matching_target, supported_wrapper, type_path,
};
use crate::callsite::current_source_info;
use crate::query;
use crate::{EnumInfo, MachineInfo, format_loaded_machine_candidates};
use crate::diagnostics::{DiagnosticMessage, compact_display};
use proc_macro2::{Span, TokenStream};
use quote::ToTokens;
use std::collections::HashSet;
use syn::{GenericArgument, LitStr, PathArguments, Type};

pub fn missing_transition_machine_error(
    machine_name: &str,
    module_path: &str,
    span: Span,
) -> TokenStream {
    let current_line = current_source_info().map(|(_, line)| line).unwrap_or_default();
    let available = available_machine_candidates_in_module(module_path);
    let suggested_machine_name = available
        .first()
        .map(|candidate| candidate.name.as_str())
        .unwrap_or(machine_name);
    let available_line = if available.is_empty() {
        "No `#[machine]` items were found in this module.".to_string()
    } else {
        format!(
            "Available `#[machine]` items in this module: {}.",
            query::format_candidates(&available)
        )
    };
    let ordering_line = available
        .iter()
        .find(|candidate| candidate.name == machine_name && candidate.line_number > current_line)
        .map(|candidate| {
            format!(
                "Source scan found `#[machine]` item `{machine_name}` later in this module on line {}. If that item is active for this build, move it above this `#[transition]` impl because Statum resolves these relationships in expansion order.",
                candidate.line_number
            )
        })
        .map(|line| format!("{line}\n"))
        .unwrap_or_default();
    let elsewhere_line = same_named_machine_candidates_elsewhere(machine_name, module_path)
        .map(|candidates| {
            format!(
                "Same-named `#[machine]` items elsewhere in this file: {}.",
                query::format_candidates(&candidates)
            )
        })
        .unwrap_or_else(|| {
            "No same-named `#[machine]` items were found in other modules of this file.".to_string()
        });
    let missing_attr_line = plain_struct_line_in_module(module_path, machine_name).map(|line| {
        format!(
            "A struct named `{machine_name}` exists on line {line}, but it is not annotated with `#[machine]`."
        )
    });
    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}`.",
        missing_attr_line.unwrap_or_else(|| "No plain struct with that name was found in this module either.".to_string())
    );
    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 return_type = func
        .return_type
        .as_ref()
        .map(|ty| ty.to_token_stream().to_string())
        .unwrap_or_else(|| "<none>".to_string());
    let machine_name = &func.machine_name;
    let uses_strict_resolution =
        crate::strict_introspection_enabled() || func.introspection.is_some();
    let strict_notes = if uses_strict_resolution {
        let suggestion = strict_introspect_return_suggestion(func, target_type)
            .map(|expanded| {
                format!(
                    "\nStrict mode help:\n  add `#[introspect(return = {expanded})]` to this method, or rewrite the signature to use that direct type."
                )
            })
            .unwrap_or_else(|| {
                "\nStrict mode help:\n  add `#[introspect(return = Machine<NextState>)]` with a direct machine path and supported wrapper shape, or rewrite the signature to use that direct type.".to_string()
            });
        format!(
            "{suggestion}\n  Source-backed alias expansion is diagnostics-only in strict mode."
        )
    } else {
        String::new()
    };

    let message = format!(
        "Error: transition method `{}<{}>::{func_name}` returns an unsupported type.\nFix: return `{machine_name}<NextState>` directly, or wrap that same machine path in a supported `Option`, `Result`, or `statum::Branch` shape.\nReason: {reason}.\n\nExpected:\n  fn {func_name}(self) -> {machine_name}<NextState>\n\nActual:\n  {return_type}\n\nNotes:\n  Supported wrappers are `::core::option::Option<...>`, `::core::result::Result<..., E>`, and `::statum::Branch<..., ...>`, plus ordinary source-declared type aliases that expand to those shapes.\n  Imported 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.{strict_notes}",
        machine_name,
        func.source_state,
    );
    compile_error_at(func.return_type_span.unwrap_or(func.span), &message)
}

fn strict_introspect_return_suggestion(
    func: &TransitionFn,
    target_type: &Type,
) -> Option<String> {
    let return_type = func.return_type.as_ref()?;
    let contexts = super::resolve::candidate_alias_resolution_contexts(func.return_type_span);

    contexts
        .iter()
        .find_map(|context| {
            strict_diagnostic_expanded_return_type(return_type, target_type, Some(context))
        })
        .or_else(|| strict_diagnostic_expanded_return_type(return_type, target_type, None))
        .map(|expanded| compact_type_display(&expanded))
}

fn strict_diagnostic_expanded_return_type(
    ty: &Type,
    target_type: &Type,
    context: Option<&AliasResolutionContext>,
) -> Option<Type> {
    let mut visited = HashSet::new();
    strict_diagnostic_expanded_return_type_inner(ty, target_type, context, &mut visited)
}

fn strict_diagnostic_expanded_return_type_inner(
    ty: &Type,
    target_type: &Type,
    context: Option<&AliasResolutionContext>,
    visited: &mut HashSet<String>,
) -> Option<Type> {
    let type_path = type_path(ty)?;

    if machine_segment_matching_target(&type_path.path, target_type).is_some() {
        return Some(ty.clone());
    }

    if let Some((expanded, alias_context, visit_key)) = expand_source_type_alias(ty, context, visited)
    {
        let result =
            strict_diagnostic_expanded_return_type_inner(&expanded, target_type, Some(&alias_context), visited)
                .or_else(|| {
                    (!collect_machine_and_states_strict(&expanded, target_type).is_empty())
                        .then_some(expanded.clone())
                });
        visited.remove(&visit_key);
        return result;
    }

    let segment = type_path.path.segments.last()?;
    supported_wrapper(&type_path.path)?;

    let original_types = extract_generic_type_refs(&segment.arguments)?;
    let mut expanded_ty = ty.clone();
    let Type::Path(expanded_type_path) = &mut expanded_ty else {
        return None;
    };
    let expanded_segment = expanded_type_path.path.segments.last_mut()?;
    let PathArguments::AngleBracketed(args) = &mut expanded_segment.arguments else {
        return None;
    };

    let mut expanded_any = false;
    let mut type_index = 0usize;
    for arg in &mut args.args {
        let GenericArgument::Type(inner_ty) = arg else {
            continue;
        };
        let original_inner = original_types.get(type_index)?;
        if let Some(expanded_inner) = strict_diagnostic_expanded_return_type_inner(
            original_inner,
            target_type,
            context,
            visited,
        ) {
            *inner_ty = expanded_inner;
            expanded_any = true;
        }
        type_index += 1;
    }

    if expanded_any && !collect_machine_and_states_strict(&expanded_ty, target_type).is_empty() {
        Some(expanded_ty)
    } else {
        None
    }
}

fn compact_type_display(ty: &Type) -> String {
    let mut display = ty.to_token_stream().to_string();
    for (from, to) in [
        (" :: ", "::"),
        (":: ", "::"),
        (" ::", "::"),
        (" < ", "<"),
        ("< ", "<"),
        (" >", ">"),
        (" , ", ", "),
    ] {
        display = display.replace(from, to);
    }
    while display.contains("> >") {
        display = display.replace("> >", ">>");
    }
    display
}

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,
) -> TokenStream {
    let actual_return = compact_display(actual_return_type);
    let 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(format!(
        "an annotation describing the same legal targets as `{actual_return}`"
    ))
    .fix("either remove the annotation or make it match the written signature.".to_string());
    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);
    }
}

fn available_machine_candidates_in_module(module_path: &str) -> Vec<query::ItemCandidate> {
    let Some((file_path, _)) = current_source_info() else {
        return Vec::new();
    };
    query::candidates_in_module(&file_path, module_path, query::ItemKind::Struct, Some("machine"))
}

fn plain_struct_line_in_module(module_path: &str, struct_name: &str) -> Option<usize> {
    let (file_path, _) = current_source_info()?;
    query::plain_item_line_in_module(
        &file_path,
        module_path,
        query::ItemKind::Struct,
        struct_name,
        Some("machine"),
    )
}

fn same_named_machine_candidates_elsewhere(
    machine_name: &str,
    module_path: &str,
) -> Option<Vec<query::ItemCandidate>> {
    let (file_path, _) = current_source_info()?;
    let candidates = query::same_named_candidates_elsewhere(
        &file_path,
        module_path,
        query::ItemKind::Struct,
        machine_name,
        Some("machine"),
    );
    (!candidates.is_empty()).then_some(candidates)
}