statum-macros 0.6.7

Proc macros for representing legal workflow and protocol states explicitly in Rust
Documentation
use proc_macro2::TokenStream;
use quote::quote;
use std::collections::HashSet;
use syn::{Ident, ItemImpl};

use macro_registry::callsite::current_source_info;
use macro_registry::query;

use crate::{
    EnumInfo, LoadedMachineLookupFailure, LoadedStateLookupFailure, MachineInfo, MachinePath,
    StateModulePath, format_loaded_machine_candidates, format_loaded_state_candidates,
    lookup_loaded_machine_in_module, lookup_loaded_state_enum, lookup_loaded_state_enum_by_name,
    to_snake_case,
};

use super::signatures::validator_state_name_from_ident;

pub(super) fn validate_validator_coverage(
    item: &ItemImpl,
    state_enum: &EnumInfo,
    persisted_type_display: &str,
    machine_name: &str,
) -> Result<(), proc_macro2::TokenStream> {
    if item.items.is_empty() {
        return Ok(());
    }

    let valid_state_names = state_enum
        .variants
        .iter()
        .map(|variant| to_snake_case(&variant.name))
        .collect::<HashSet<_>>();
    let existing = item
        .items
        .iter()
        .filter_map(|item| {
            if let syn::ImplItem::Fn(func) = item {
                validator_state_name_from_ident(&func.sig.ident)
            } else {
                None
            }
        })
        .collect::<HashSet<_>>();
    let unknown = existing
        .iter()
        .filter(|name| !valid_state_names.contains(*name))
        .map(|name| format!("is_{name}"))
        .collect::<Vec<_>>();

    if !unknown.is_empty() {
        let unknown_list = unknown.join(", ");
        let state_enum_name = &state_enum.name;
        let valid_list = state_enum
            .variants
            .iter()
            .map(|variant| format!("is_{}", to_snake_case(&variant.name)))
            .collect::<Vec<_>>()
            .join(", ");
        return Err(quote! {
            compile_error!(concat!(
                "Error: `#[validators(",
                #machine_name,
                ")]` on `impl ",
                #persisted_type_display,
                "` defines methods that do not match any variant in `",
                #state_enum_name,
                "`: ",
                #unknown_list,
                ".\n",
                "Valid validator methods for `",
                #machine_name,
                "` are: ",
                #valid_list,
                "."
            ));
        });
    }

    let mut missing = Vec::new();
    for variant in &state_enum.variants {
        let variant_name = to_snake_case(&variant.name);
        if !existing.contains(&variant_name) {
            missing.push(variant_name);
        }
    }

    if !missing.is_empty() {
        let missing_list = missing
            .iter()
            .map(|name| format!("is_{name}"))
            .collect::<Vec<_>>()
            .join(", ");
        let state_enum_name = &state_enum.name;
        return Err(quote! {
            compile_error!(concat!(
                "Error: `#[validators(",
                #machine_name,
                ")]` on `impl ",
                #persisted_type_display,
                "` is missing validator methods for `",
                #state_enum_name,
                "`: ",
                #missing_list,
                ".\n",
                "Fix: add one validator per state variant (snake_case), e.g. `fn is_draft(&self) -> Result<()>`."
            ));
        });
    }

    Ok(())
}

pub(super) fn resolve_machine_metadata(
    module_path: &str,
    machine_ident: &Ident,
) -> Result<MachineInfo, TokenStream> {
    let module_path_key: MachinePath = module_path.into();
    let machine_name = machine_ident.to_string();
    lookup_loaded_machine_in_module(&module_path_key, &machine_name).map_err(|failure| {
        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.as_str());
        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 `#[validators]` 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 authority_line = match failure {
            LoadedMachineLookupFailure::NotFound => {
                "Statum only resolves `#[machine]` items that have already expanded before this `#[validators]` impl.".to_string()
            }
            LoadedMachineLookupFailure::Ambiguous(candidates) => format!(
                "Loaded `#[machine]` candidates were ambiguous: {}.",
                format_loaded_machine_candidates(&candidates)
            ),
        };
        let message = format!(
            "Error: no resolved `#[machine]` named `{machine_name}` was found in module `{module_path}`.\n{authority_line}\n{ordering_line}{}\n{elsewhere_line}\n{available_line}\nHelp: point `#[validators(...)]` at the Statum machine type in this module and declare that `#[machine]` item before this validators impl.\nCorrect shape: `#[validators({suggested_machine_name})] impl PersistedRow {{ ... }}` 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()),
        );
        quote! {
            compile_error!(#message);
        }
    })
}

pub(super) fn resolve_state_enum_info(
    module_path: &str,
    machine_metadata: &MachineInfo,
) -> Result<EnumInfo, TokenStream> {
    let state_path_key: StateModulePath = module_path.into();
    let machine_name = machine_metadata.name.clone();
    let expected_state_name = machine_metadata.state_generic_name.as_deref();
    let state_enum_info = match expected_state_name {
        Some(expected_name) => lookup_loaded_state_enum_by_name(&state_path_key, expected_name),
        None => lookup_loaded_state_enum(&state_path_key),
    };
    state_enum_info.map_err(|failure| {
        let current_line = current_source_info().map(|(_, line)| line).unwrap_or_default();
        let available = available_state_candidates_in_module(module_path);
        let available_line = if available.is_empty() {
            "No `#[state]` enums were found in this module.".to_string()
        } else {
            format!(
                "Available `#[state]` enums in this module: {}.",
                query::format_candidates(&available)
            )
        };
        let elsewhere_line = expected_state_name
            .and_then(|name| same_named_state_candidates_elsewhere(name, module_path))
            .map(|candidates| {
                format!(
                    "Same-named `#[state]` enums elsewhere in this file: {}.",
                    query::format_candidates(&candidates)
                )
            })
            .unwrap_or_else(|| "No same-named `#[state]` enums were found in other modules of this file.".to_string());
        let expected_line = expected_state_name
            .map(|name| {
                format!(
                    "Machine `{machine_name}` expects its first generic parameter to name `#[state]` enum `{name}`."
                )
            })
            .unwrap_or_else(|| {
                format!(
                    "Machine `{machine_name}` did not expose a resolvable first generic parameter for its `#[state]` enum."
                )
            });
        let ordering_line = expected_state_name.and_then(|name| {
            available
                .iter()
                .find(|candidate| {
                    candidate.name == name && candidate.line_number > current_line
                })
                .map(|candidate| {
                    format!(
                        "Source scan found `#[state]` enum `{name}` later in this module on line {}. If that item is active for this build, move it above the machine and this `#[validators]` impl because Statum resolves these relationships in expansion order.",
                        candidate.line_number
                    )
                })
        });
        let ordering_line = ordering_line
            .map(|line| format!("{line}\n"))
            .unwrap_or_default();
        let missing_attr_line = expected_state_name.as_ref().and_then(|name| {
            plain_enum_line_in_module(module_path, name).map(|line| {
                format!("An enum named `{name}` exists on line {line}, but it is not annotated with `#[state]`.")
            })
        });
        let authority_line = match failure {
            LoadedStateLookupFailure::NotFound => {
                "Statum only resolves `#[state]` enums that have already expanded before this `#[validators]` impl.".to_string()
            }
            LoadedStateLookupFailure::Ambiguous(candidates) => format!(
                "Loaded `#[state]` candidates were ambiguous: {}.",
                format_loaded_state_candidates(&candidates)
            ),
        };
        let message = format!(
            "Error: could not resolve the `#[state]` enum for machine `{machine_name}` in module `{module_path}`.\n{expected_line}\n{authority_line}\n{ordering_line}{}\n{elsewhere_line}\n{available_line}\nHelp: make sure the machine's first generic names the right `#[state]` enum in this module and declare that `#[state]` enum before the machine and validators impl.\nCorrect shape: `struct {machine_name}<ExpectedState> {{ ... }}` where `ExpectedState` is a `#[state]` enum declared in `{module_path}`.",
            missing_attr_line.unwrap_or_else(|| "No plain enum with that expected name was found in this module either.".to_string())
        );
        quote! {
            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 available_state_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::Enum, Some("state"))
}

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

fn same_named_state_candidates_elsewhere(
    state_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::Enum,
        state_name,
        Some("state"),
    );
    (!candidates.is_empty()).then_some(candidates)
}

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 plain_enum_line_in_module(module_path: &str, enum_name: &str) -> Option<usize> {
    let (file_path, _) = current_source_info()?;
    query::plain_item_line_in_module(
        &file_path,
        module_path,
        query::ItemKind::Enum,
        enum_name,
        Some("state"),
    )
}