use super::parse::{TransitionFn, TransitionIntrospectAttr};
use super::resolve::{
AliasResolutionContext, SupportedWrapper, candidate_alias_resolution_contexts,
collect_machine_and_states_in_context, collect_machine_and_states_strict,
expand_source_type_alias, extract_first_generic_type_ref, extract_generic_type_refs,
machine_segment_matching_target, parse_primary_machine_and_state_strict, supported_wrapper,
type_path,
};
use crate::source::{current_source_info, format_candidates};
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: {}.",
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: {}.",
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(compact_type_display)
.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 observed = observed_return_shape(func, target_type);
let strict_help = if uses_strict_resolution {
let suggestion = strict_introspect_return_suggestion(func, target_type)
.map(|expanded| {
format!(
"add `#[introspect(return = {expanded})]` to this method, or rewrite the signature to use that direct type."
)
})
.unwrap_or_else(|| {
"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}\nSource-backed alias expansion is diagnostics-only in strict mode."
)
} else {
String::new()
};
let expected = observed
.as_ref()
.map(|shape| shape.canonical_wrapped_signature(func_name, machine_name))
.unwrap_or_else(|| format!("`fn {func_name}(self) -> {machine_name}<NextState>`"));
let fix = observed
.as_ref()
.map(|shape| shape.fix_message(func_name, machine_name))
.unwrap_or_else(|| {
format!(
"return `{machine_name}<NextState>` directly, or wrap that same machine path in a supported `Option`, `Result`, or `statum::Branch` shape."
)
});
let mut message = DiagnosticMessage::new(format!(
"transition method `{}<{}>::{func_name}` returns an unsupported type.",
machine_name, func.source_state,
))
.found(format!("`fn {func_name}(self) -> {return_type}`"))
.expected(expected)
.reason(reason.to_string())
.fix(fix);
if let Some(shape) = &observed
&& let Some(primary_branch) = &shape.primary_branch
{
message = message.section("Primary branch", format!("`{primary_branch}`"));
}
if let Some(shape) = &observed
&& !shape.secondary_machine_branches.is_empty()
{
message = message.section(
"Observed machine branches",
shape
.secondary_machine_branches
.iter()
.map(|branch| format!("`{branch}`"))
.collect::<Vec<_>>()
.join(", "),
);
}
message = message.note(
"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.",
);
if !strict_help.is_empty() {
message = message.help(strict_help);
}
compile_error_at(func.return_type_span.unwrap_or(func.span), &message.render())
}
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(|| {
parse_primary_machine_and_state_strict(&expanded, target_type)
.is_some()
.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 && parse_primary_machine_and_state_strict(&expanded_ty, target_type).is_some() {
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
}
struct ObservedReturnShape {
primary_branch: Option<String>,
secondary_machine_branches: Vec<String>,
wrapper: Option<SupportedWrapper>,
canonical_state: Option<String>,
}
impl ObservedReturnShape {
fn canonical_machine_target(&self, machine_name: &str) -> String {
match self.canonical_state.as_deref() {
Some(state) => format!("{machine_name}<{state}>"),
None => format!("{machine_name}<NextState>"),
}
}
fn canonical_annotation(&self, machine_name: &str) -> String {
let machine_target = self.canonical_machine_target(machine_name);
match self.wrapper {
Some(SupportedWrapper::Option) => {
format!("::core::option::Option<{machine_target}>")
}
Some(SupportedWrapper::Result) => {
format!("::core::result::Result<{machine_target}, E>")
}
Some(SupportedWrapper::Branch) => {
format!("::statum::Branch<{machine_target}, OtherBranch>")
}
None => machine_target,
}
}
fn canonical_wrapped_signature(&self, func_name: &syn::Ident, machine_name: &str) -> String {
format!("`fn {func_name}(self) -> {}`", self.canonical_annotation(machine_name))
}
fn fix_message(&self, func_name: &syn::Ident, machine_name: &str) -> String {
let machine_target = self.canonical_machine_target(machine_name);
match self.wrapper {
Some(SupportedWrapper::Option)
| Some(SupportedWrapper::Result)
| Some(SupportedWrapper::Branch) => format!(
"move `{machine_target}` into the primary branch, for example with {}, or return `{machine_target}` directly if you do not need the wrapper.",
self.canonical_wrapped_signature(func_name, machine_name)
),
None => format!("return `{machine_target}` directly."),
}
}
}
fn observed_return_shape(func: &TransitionFn, target_type: &Type) -> Option<ObservedReturnShape> {
let return_type = func.return_type.as_ref()?;
let wrapper = raw_wrapper_kind(return_type);
let primary_branch = primary_branch_display(return_type);
let mut machine_branches = resolved_machine_branches(func, target_type);
let canonical_state = parse_primary_machine_and_state_strict(return_type, target_type)
.map(|(_, state)| state)
.or_else(|| {
machine_branches
.first()
.map(|branch| state_name_from_machine_target(branch).to_string())
});
if let Some(state) = canonical_state.as_deref() {
let canonical_machine = format!("{}<{state}>", func.machine_name);
machine_branches.retain(|branch| branch != &canonical_machine);
}
Some(ObservedReturnShape {
primary_branch,
secondary_machine_branches: machine_branches,
wrapper,
canonical_state,
})
}
fn resolved_machine_branches(func: &TransitionFn, target_type: &Type) -> Vec<String> {
let Some(return_type) = func.return_type.as_ref() else {
return Vec::new();
};
let uses_strict_resolution =
crate::strict_introspection_enabled() || func.introspection.is_some();
let targets = if uses_strict_resolution {
collect_machine_and_states_strict(return_type, target_type)
} else {
let contexts = candidate_alias_resolution_contexts(func.return_type_span);
contexts
.iter()
.map(|context| {
collect_machine_and_states_in_context(return_type, target_type, Some(context))
})
.find(|states| !states.is_empty())
.unwrap_or_else(|| collect_machine_and_states_in_context(return_type, target_type, None))
};
targets
.into_iter()
.map(|(machine, state)| format!("{machine}<{state}>"))
.collect()
}
fn raw_wrapper_kind(ty: &Type) -> Option<SupportedWrapper> {
let type_path = type_path(ty)?;
supported_wrapper(&type_path.path)
}
fn primary_branch_display(ty: &Type) -> Option<String> {
let type_path = type_path(ty)?;
let segment = type_path.path.segments.last()?;
match supported_wrapper(&type_path.path) {
Some(_) => extract_first_generic_type_ref(&segment.arguments).map(compact_type_display),
None => Some(compact_type_display(ty)),
}
}
fn state_name_from_machine_target(machine_target: &str) -> &str {
machine_target
.split_once('<')
.and_then(|(_, state)| state.strip_suffix('>'))
.unwrap_or("NextState")
}
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 actual_return = compact_display(actual_return_type);
let observed = observed_return_shape(func, target_type);
let annotation_primary = primary_branch_display(&introspection.return_type);
let expected = observed
.as_ref()
.map(|shape| {
format!(
"`#[introspect(return = {})]` and {}",
shape.canonical_annotation(&func.machine_name),
shape.canonical_wrapped_signature(&func.name, &func.machine_name)
)
})
.unwrap_or_else(|| format!("an annotation describing the same legal targets as `{actual_return}`"));
let fix = observed
.as_ref()
.map(|shape| {
format!(
"make the written primary branch `{}` so it matches `#[introspect(return = {})]`, or rewrite the method to {}.",
shape.canonical_machine_target(&func.machine_name),
shape.canonical_annotation(&func.machine_name),
shape.canonical_wrapped_signature(&func.name, &func.machine_name)
)
})
.unwrap_or_else(|| "either remove the annotation or make it match the written signature.".to_string());
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(expected)
.fix(fix);
if let Some(primary_branch) = observed.as_ref().and_then(|shape| shape.primary_branch.as_ref()) {
message = message.section("Written primary branch", format!("`{primary_branch}`"));
}
if let Some(primary_branch) = annotation_primary {
message = message.section("Annotated primary branch", format!("`{primary_branch}`"));
}
if let Some(shape) = &observed
&& !shape.secondary_machine_branches.is_empty()
{
message = message.section(
"Observed machine branches",
shape
.secondary_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);
}
}
fn available_machine_candidates_in_module(module_path: &str) -> Vec<crate::source::ItemCandidate> {
let Some((file_path, _)) = current_source_info() else {
return Vec::new();
};
crate::source::candidates_in_module(
&file_path,
module_path,
crate::source::ItemKind::Struct,
Some("machine"),
)
}
fn plain_struct_line_in_module(module_path: &str, struct_name: &str) -> Option<usize> {
let (file_path, _) = current_source_info()?;
crate::source::plain_item_line_in_module(
&file_path,
module_path,
crate::source::ItemKind::Struct,
struct_name,
Some("machine"),
)
}
fn same_named_machine_candidates_elsewhere(
machine_name: &str,
module_path: &str,
) -> Option<Vec<crate::source::ItemCandidate>> {
let (file_path, _) = current_source_info()?;
let candidates = crate::source::same_named_candidates_elsewhere(
&file_path,
module_path,
crate::source::ItemKind::Struct,
machine_name,
Some("machine"),
);
(!candidates.is_empty()).then_some(candidates)
}