diff --git a/README.md b/README.md index 34b68a4..8e42206 100644 --- a/README.md +++ b/README.md @@ -100,11 +100,19 @@ they can become typed machines. If you add derives, place them below `#[state]` and `#[machine]`: ```rust +# use statum::{machine, state}; +# #[state] +# #[derive(Debug, Clone)] +# enum LightState { +# Off, +# } #[machine] #[derive(Debug, Clone)] struct LightSwitch { name: String, } + +# fn main() {} ``` That avoids the common `missing fields marker and state_data` error. diff --git a/macro_registry/README.md b/macro_registry/README.md index 67806a5..5bad05a 100644 --- a/macro_registry/README.md +++ b/macro_registry/README.md @@ -37,6 +37,7 @@ Simple query example: ```rust use macro_registry::query::{ItemKind, candidates_in_module}; +# let file_path = "/tmp/lib.rs"; let machines = candidates_in_module( file_path, "crate::workflow", diff --git a/macro_registry/src/lib.rs b/macro_registry/src/lib.rs index ee22481..2e09a96 100644 --- a/macro_registry/src/lib.rs +++ b/macro_registry/src/lib.rs @@ -5,3 +5,7 @@ pub mod analysis; pub mod callsite; pub mod query; pub mod registry; + +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +mod readme_doctests {} diff --git a/module_path_extractor/src/lib.rs b/module_path_extractor/src/lib.rs index dbdc811..8bcc682 100644 --- a/module_path_extractor/src/lib.rs +++ b/module_path_extractor/src/lib.rs @@ -4,6 +4,10 @@ mod cache; mod parser; mod pathing; +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +mod readme_doctests {} + use std::path::Path; use crate::cache::{ @@ -185,6 +189,33 @@ mod tests { let _ = fs::remove_dir_all(crate_dir); } + #[test] + fn find_module_path_in_file_separates_sibling_modules_with_similar_shapes() { + let crate_dir = unique_temp_dir("sibling_modules"); + let src = crate_dir.join("src"); + let lib = src.join("lib.rs"); + + write_file( + &lib, + "mod alpha {\n mod support {\n pub struct Text;\n }\n\n pub enum WorkflowState {\n Draft,\n }\n\n pub struct Row {\n pub status: &'static str,\n }\n}\n\nmod beta {\n mod support {\n pub struct Text;\n }\n\n pub enum WorkflowState {\n Draft,\n }\n\n pub struct Row {\n pub status: &'static str,\n }\n}\n", + ); + + assert_eq!( + find_module_path_in_file(&lib.to_string_lossy(), 6, &src).as_deref(), + Some("alpha") + ); + assert_eq!( + find_module_path_in_file(&lib.to_string_lossy(), 20, &src).as_deref(), + Some("beta") + ); + assert_eq!( + find_module_path_in_file(&lib.to_string_lossy(), 19, &src).as_deref(), + Some("beta") + ); + + let _ = fs::remove_dir_all(crate_dir); + } + #[test] fn find_module_path_in_file_ignores_mod_tokens_in_comments_and_raw_strings() { let crate_dir = unique_temp_dir("comments_and_raw_strings"); diff --git a/module_path_extractor/src/parser.rs b/module_path_extractor/src/parser.rs index 830eecb..a1c6149 100644 --- a/module_path_extractor/src/parser.rs +++ b/module_path_extractor/src/parser.rs @@ -68,6 +68,45 @@ fn raw_identifier_len(bytes: &[u8], start: usize) -> Option { Some(idx - start) } +fn char_literal_len(content: &str, bytes: &[u8], start: usize) -> Option { + if bytes.get(start) != Some(&b'\'') { + return None; + } + + let mut idx = start + 1; + if idx >= bytes.len() || bytes[idx] == b'\n' { + return None; + } + + if bytes[idx] == b'\\' { + idx += 1; + if idx >= bytes.len() { + return None; + } + + if bytes[idx] == b'u' && bytes.get(idx + 1) == Some(&b'{') { + idx += 2; + while idx < bytes.len() && bytes[idx] != b'}' { + if bytes[idx] == b'\n' { + return None; + } + idx += 1; + } + if bytes.get(idx) != Some(&b'}') { + return None; + } + idx += 1; + } else { + idx += 1; + } + } else { + let next = content.get(idx..)?.chars().next()?; + idx += next.len_utf8(); + } + + (bytes.get(idx) == Some(&b'\'')).then_some(idx - start + 1) +} + fn handle_identifier_token( token: &str, expect_mod_ident: &mut bool, @@ -98,7 +137,6 @@ fn build_line_module_paths(content: &str) -> Vec { LineComment, BlockComment { depth: usize }, String { escaped: bool }, - Char { escaped: bool }, RawString { hashes: usize }, } @@ -170,17 +208,6 @@ fn build_line_module_paths(content: &str) -> Vec { i += 1; continue; } - Mode::Char { escaped } => { - if byte == b'\\' && !escaped { - mode = Mode::Char { escaped: true }; - } else if byte == b'\'' && !escaped { - mode = Mode::Normal; - } else { - mode = Mode::Char { escaped: false }; - } - i += 1; - continue; - } Mode::RawString { hashes } => { if byte == b'"' { let mut matched = true; @@ -217,8 +244,12 @@ fn build_line_module_paths(content: &str) -> Vec { i += 1; continue; } + if let Some(consumed) = char_literal_len(content, bytes, i) { + i += consumed; + continue; + } if byte == b'\'' { - mode = Mode::Char { escaped: false }; + // Lifetimes and labels are not string delimiters and should not hide module tokens. i += 1; continue; } diff --git a/statum-core/src/lib.rs b/statum-core/src/lib.rs index 53293a9..babec9b 100644 --- a/statum-core/src/lib.rs +++ b/statum-core/src/lib.rs @@ -8,6 +8,10 @@ //! - runtime error and result types //! - projection helpers for event-log style rebuilds +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +mod readme_doctests {} + mod introspection; pub mod projection; diff --git a/statum-examples/src/lib.rs b/statum-examples/src/lib.rs index 8c274e3..2a7215b 100644 --- a/statum-examples/src/lib.rs +++ b/statum-examples/src/lib.rs @@ -3,3 +3,7 @@ pub mod showcases; pub mod toy_demos; + +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +mod readme_doctests {} diff --git a/statum-macros/src/lib.rs b/statum-macros/src/lib.rs index 405a126..8fad6ec 100644 --- a/statum-macros/src/lib.rs +++ b/statum-macros/src/lib.rs @@ -12,6 +12,10 @@ //! - [`transition`] for validating legal transition impls //! - [`validators`] for rebuilding typed machines from persisted data +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +mod readme_doctests {} + mod syntax; moddef::moddef!( @@ -25,9 +29,16 @@ moddef::moddef!( } ); -pub(crate) use syntax::{ItemTarget, ModulePath, extract_derives}; +pub(crate) use syntax::{ + ItemTarget, ModulePath, SourceFingerprint, crate_root_for_file, current_crate_root, + extract_derives, source_file_fingerprint, +}; -use crate::{MachinePath, ensure_machine_loaded_by_name, unique_loaded_machine_elsewhere}; +use crate::{ + LoadedMachineLookupFailure, MachinePath, ambiguous_transition_machine_error, + ambiguous_transition_machine_fallback_error, lookup_loaded_machine_in_module, + lookup_unique_loaded_machine_by_name, +}; use macro_registry::callsite::current_module_path_opt; use proc_macro::TokenStream; use proc_macro2::Span; @@ -60,7 +71,7 @@ pub fn state(_attr: TokenStream, item: TokenStream) -> TokenStream { store_state_enum(&enum_info); // Generate structs and implementations dynamically - let expanded = generate_state_impls(&enum_info.module_path); + let expanded = generate_state_impls(&enum_info); TokenStream::from(expanded) } @@ -123,10 +134,34 @@ pub fn transition( }; let machine_path: MachinePath = module_path.clone().into(); - // `include!` gives the transition macro the included file as its source context, - // so exact module lookup can miss the already-loaded parent machine. - let machine_info_owned = ensure_machine_loaded_by_name(&machine_path, &tr_impl.machine_name) - .or_else(|| unique_loaded_machine_elsewhere(&tr_impl.machine_name)); + let machine_info_owned = + match lookup_loaded_machine_in_module(&machine_path, &tr_impl.machine_name) { + Ok(info) => Some(info), + Err(LoadedMachineLookupFailure::Ambiguous(candidates)) => { + return ambiguous_transition_machine_error( + &tr_impl.machine_name, + &module_path, + &candidates, + tr_impl.machine_span, + ) + .into(); + } + Err(LoadedMachineLookupFailure::NotFound) => { + match lookup_unique_loaded_machine_by_name(&tr_impl.machine_name) { + Ok(info) => Some(info), + Err(LoadedMachineLookupFailure::Ambiguous(candidates)) => { + return ambiguous_transition_machine_fallback_error( + &tr_impl.machine_name, + &module_path, + &candidates, + tr_impl.machine_span, + ) + .into(); + } + Err(LoadedMachineLookupFailure::NotFound) => None, + } + } + }; let machine_info = match machine_info_owned.as_ref() { Some(info) => info, None => { diff --git a/statum-macros/src/machine/emission.rs b/statum-macros/src/machine/emission.rs index 7ecf820..038f887 100644 --- a/statum-macros/src/machine/emission.rs +++ b/statum-macros/src/machine/emission.rs @@ -6,32 +6,9 @@ use crate::state::{ParsedEnumInfo, ParsedVariantInfo}; use crate::{EnumInfo, to_snake_case}; use super::metadata::{ParsedMachineInfo, field_type_alias_name, is_rust_analyzer}; -use super::registry::get_machine_map; use super::{MachineInfo, transition_slice_ident}; pub fn generate_machine_impls(machine_info: &MachineInfo, item: &ItemStruct) -> proc_macro2::TokenStream { - let map_guard = match get_machine_map().read() { - Ok(guard) => guard, - Err(_) => { - let message = format!( - "Internal error: machine metadata lock poisoned while generating `{}` in module `{}`.", - machine_info.name, machine_info.module_path - ); - return quote! { - compile_error!(#message); - }; - } - }; - let Some(machine_info) = map_guard.get(&machine_info.module_path) else { - let message = format!( - "Internal error: machine metadata for `{}` in module `{}` was not cached during code generation.\nTry re-running `cargo check` and make sure `#[machine]` is applied in that module.", - machine_info.name, machine_info.module_path - ); - return quote! { - compile_error!(#message); - }; - }; - let state_enum = match machine_info.get_matching_state_enum() { Ok(enum_info) => enum_info, Err(err) => return err, diff --git a/statum-macros/src/machine/metadata.rs b/statum-macros/src/machine/metadata.rs index 76025f4..3d3d1be 100644 --- a/statum-macros/src/machine/metadata.rs +++ b/statum-macros/src/machine/metadata.rs @@ -1,15 +1,13 @@ use macro_registry::callsite::{current_source_info, module_path_for_line}; -use macro_registry::registry::SourceContext; use macro_registry::query; -use macro_registry::registry; use proc_macro2::{Span, TokenStream}; use quote::{format_ident, quote, ToTokens}; use syn::{Generics, Ident, ItemStruct, LitStr, Type, Visibility}; use crate::{ - EnumInfo, ModulePath, StateModulePath, ensure_state_enum_loaded, - ensure_state_enum_loaded_by_name, ensure_state_enum_loaded_by_name_from_source, - ensure_state_enum_loaded_from_source, extract_derives, + EnumInfo, LoadedStateLookupFailure, ModulePath, SourceFingerprint, StateModulePath, + crate_root_for_file, extract_derives, format_loaded_state_candidates, + lookup_loaded_state_enum, lookup_loaded_state_enum_by_name, source_file_fingerprint, }; pub type MachinePath = ModulePath; @@ -25,6 +23,8 @@ pub struct MachineInfo { pub generics: String, pub state_generic_name: Option, pub file_path: Option, + pub crate_root: Option, + pub file_fingerprint: Option, } impl MachineInfo { @@ -68,29 +68,12 @@ impl MachineInfo { pub fn get_matching_state_enum(&self) -> Result { let state_path: StateModulePath = self.module_path.clone(); - // Included transition fragments must resolve the state enum against the - // machine's source file, not the include file's pseudo-module context. - let source = self - .file_path - .as_ref() - .map(|file_path| SourceContext::new(file_path.clone(), self.line_number)); let state_enum = if let Some(expected_name) = self.state_generic_name.as_deref() { - source - .as_ref() - .and_then(|source| { - ensure_state_enum_loaded_by_name_from_source(&state_path, expected_name, source) - }) - .or_else(|| ensure_state_enum_loaded_by_name(&state_path, expected_name)) + lookup_loaded_state_enum_by_name(&state_path, expected_name) } else { - source - .as_ref() - .and_then(|source| ensure_state_enum_loaded_from_source(&state_path, source)) - .or_else(|| ensure_state_enum_loaded(&state_path)) + lookup_loaded_state_enum(&state_path) }; - let Some(state_enum) = state_enum else { - return Err(missing_state_enum_error(self)); - }; - Ok(state_enum) + state_enum.map_err(|failure| missing_state_enum_error(self, failure)) } pub fn from_item_struct(item: &ItemStruct) -> syn::Result { @@ -115,6 +98,8 @@ impl MachineInfo { let module_path: MachinePath = module_path.into(); let fields = collect_fields(item); + let crate_root = crate_root_for_file(&file_path); + let file_fingerprint = source_file_fingerprint(&file_path); Ok(Self { name: item.ident.to_string(), @@ -131,15 +116,19 @@ impl MachineInfo { generics: item.generics.to_token_stream().to_string(), state_generic_name: extract_state_generic_name(&item.generics), file_path: Some(file_path), + crate_root, + file_fingerprint, }) } + #[cfg(test)] pub fn from_item_struct_with_module(item: &ItemStruct, module_path: &MachinePath) -> Option { if item.generics.params.is_empty() { return None; } let line_number = current_source_info().map(|(_, line)| line).unwrap_or_default(); + let file_path = current_source_info().map(|(path, _)| path); Some(Self { name: item.ident.to_string(), vis: item.vis.to_token_stream().to_string(), @@ -154,21 +143,17 @@ impl MachineInfo { line_number, generics: item.generics.to_token_stream().to_string(), state_generic_name: extract_state_generic_name(&item.generics), - file_path: current_source_info().map(|(path, _)| path), + crate_root: file_path + .as_deref() + .and_then(crate_root_for_file), + file_fingerprint: file_path + .as_deref() + .and_then(source_file_fingerprint), + file_path, }) } } -impl registry::RegistryValue for MachineInfo { - fn file_path(&self) -> Option<&str> { - self.file_path.as_deref() - } - - fn set_file_path(&mut self, file_path: String) { - self.file_path = Some(file_path); - } -} - #[derive(Clone)] pub struct MachineField { pub name: String, @@ -234,7 +219,10 @@ fn extract_state_generic_name(generics: &Generics) -> Option { } } -fn missing_state_enum_error(machine_info: &MachineInfo) -> TokenStream { +fn missing_state_enum_error( + machine_info: &MachineInfo, + failure: LoadedStateLookupFailure, +) -> TokenStream { if is_rust_analyzer() { return TokenStream::new(); } @@ -260,6 +248,22 @@ fn missing_state_enum_error(machine_info: &MachineInfo) -> TokenStream { query::format_candidates(&available) ) }; + let ordering_line = expected.and_then(|name| { + available + .iter() + .find(|candidate| { + candidate.name == name && candidate.line_number > machine_info.line_number + }) + .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 machine `{}` because Statum resolves these relationships in expansion order.", + candidate.line_number, machine_info.name + ) + }) + }); + let ordering_line = ordering_line + .map(|line| format!("{line}\n")) + .unwrap_or_default(); let elsewhere_line = expected .and_then(|name| { same_named_state_candidates_elsewhere( @@ -280,10 +284,21 @@ fn missing_state_enum_error(machine_info: &MachineInfo) -> TokenStream { 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 `#[machine]` declaration.".to_string() + } + LoadedStateLookupFailure::Ambiguous(candidates) => format!( + "Loaded `#[state]` candidates were ambiguous: {}.", + format_loaded_state_candidates(&candidates) + ), + }; let message = format!( - "Failed to resolve the #[state] enum for machine `{}`.\n{}\n{}\n{}\n{}\nHelp: make sure the machine's first generic names the right `#[state]` enum in this module.\nCorrect shape: `struct {} {{ ... }}` where `ExpectedState` is a `#[state]` enum in `{}`.", + "Failed to resolve the #[state] enum for machine `{}`.\n{}\n{}\n{}{}\n{}\n{}\nHelp: make sure the machine's first generic names the right `#[state]` enum in this module and declare that `#[state]` enum before the machine.\nCorrect shape: `struct {} {{ ... }}` where `ExpectedState` is a `#[state]` enum in `{}`.", machine_info.name, expected_line, + authority_line, + ordering_line, missing_attr_line.unwrap_or_else(|| "No plain enum with that expected name was found in that module either.".to_string()), elsewhere_line, available_line, diff --git a/statum-macros/src/machine/mod.rs b/statum-macros/src/machine/mod.rs index 3e4c2cd..1285477 100644 --- a/statum-macros/src/machine/mod.rs +++ b/statum-macros/src/machine/mod.rs @@ -8,5 +8,8 @@ pub(crate) use emission::transition_support_module_ident; pub use emission::generate_machine_impls; pub(crate) use introspection::{to_shouty_snake_identifier, transition_slice_ident}; pub use metadata::{MachineInfo, MachinePath}; -pub use registry::{ensure_machine_loaded_by_name, store_machine_struct, unique_loaded_machine_elsewhere}; +pub use registry::{ + LoadedMachineLookupFailure, format_loaded_machine_candidates, + lookup_loaded_machine_in_module, lookup_unique_loaded_machine_by_name, store_machine_struct, +}; pub use validation::{invalid_machine_target_error, validate_machine_struct}; diff --git a/statum-macros/src/machine/registry.rs b/statum-macros/src/machine/registry.rs index 4834e12..b1b2fd6 100644 --- a/statum-macros/src/machine/registry.rs +++ b/statum-macros/src/machine/registry.rs @@ -1,107 +1,113 @@ -use macro_registry::analysis::{FileAnalysis, StructEntry}; -use macro_registry::registry; -use std::collections::HashMap; -use std::sync::RwLock; +use std::sync::{OnceLock, RwLock}; use super::{MachineInfo, MachinePath}; +use crate::{current_crate_root, source_file_fingerprint}; -static MACHINE_MAP: registry::StaticRegistry = registry::StaticRegistry::new(); +static LOADED_MACHINES: OnceLock>> = OnceLock::new(); -struct MachineRegistryDomain; - -impl registry::RegistryDomain for MachineRegistryDomain { - type Key = MachinePath; - type Value = MachineInfo; - type Entry = StructEntry; +#[derive(Clone)] +pub enum LoadedMachineLookupFailure { + NotFound, + Ambiguous(Vec), +} - fn entries(analysis: &FileAnalysis) -> &[Self::Entry] { - &analysis.structs - } +fn loaded_machines() -> &'static RwLock> { + LOADED_MACHINES.get_or_init(|| RwLock::new(Vec::new())) +} - fn entry_line(entry: &Self::Entry) -> usize { - entry.line_number - } +fn same_loaded_machine(left: &MachineInfo, right: &MachineInfo) -> bool { + left.name == right.name + && left.module_path.as_ref() == right.module_path.as_ref() + && left.file_path == right.file_path + && left.line_number == right.line_number +} - fn build_value(entry: &Self::Entry, module_path: &Self::Key) -> Option { - let mut value = MachineInfo::from_item_struct_with_module(&entry.item, module_path)?; - value.line_number = entry.line_number; - Some(value) - } +fn upsert_loaded_machine(machine_info: &MachineInfo) { + let Ok(mut machines) = loaded_machines().write() else { + return; + }; - fn matches_entry(entry: &Self::Entry) -> bool { - entry.attrs.iter().any(|attr| attr == "machine") + if let Some(existing) = machines + .iter_mut() + .find(|existing| same_loaded_machine(existing, machine_info)) + { + *existing = machine_info.clone(); + } else { + machines.push(machine_info.clone()); } +} - fn entry_hint(entry: &Self::Entry) -> Option { - Some(entry.item.ident.to_string()) - } +fn loaded_machine_candidates_matching(matches: F) -> Vec +where + F: Fn(&MachineInfo) -> bool, +{ + let current_crate_root = current_crate_root(); + let Ok(machines) = loaded_machines().read() else { + return Vec::new(); + }; + + machines + .iter() + .filter(|machine| loaded_machine_is_current(machine, current_crate_root.as_deref())) + .filter(|machine| matches(machine)) + .cloned() + .collect() } -impl registry::NamedRegistryDomain for MachineRegistryDomain { - fn entry_name(entry: &Self::Entry) -> String { - entry.item.ident.to_string() +fn loaded_machine_is_current(machine: &MachineInfo, current_crate_root: Option<&str>) -> bool { + if current_crate_root.is_some() && machine.crate_root.as_deref() != current_crate_root { + return false; } - fn value_name(value: &Self::Value) -> String { - value.name.clone() + match (machine.file_path.as_deref(), machine.file_fingerprint.as_ref()) { + (Some(file_path), Some(fingerprint)) => { + source_file_fingerprint(file_path).as_ref() == Some(fingerprint) + } + _ => true, } } -pub(super) fn get_machine_map() -> &'static RwLock> { - MACHINE_MAP.map() -} - -fn get_machine(machine_path: &MachinePath) -> Option { - MACHINE_MAP.get_cloned(machine_path) +fn lookup_loaded_machine_candidates( + candidates: Vec, +) -> Result { + match candidates.len() { + 0 => Err(LoadedMachineLookupFailure::NotFound), + 1 => Ok(candidates.into_iter().next().expect("single candidate")), + _ => Err(LoadedMachineLookupFailure::Ambiguous(candidates)), + } } -pub fn ensure_machine_loaded_by_name( +pub fn lookup_loaded_machine_in_module( machine_path: &MachinePath, machine_name: &str, -) -> Option { - if let Some(existing) = get_machine(machine_path) - && existing.name == machine_name - { - return Some(existing); - } - - registry::ensure_loaded_by_name::(&MACHINE_MAP, machine_path, machine_name) +) -> Result { + lookup_loaded_machine_candidates(loaded_machine_candidates_matching(|machine| { + machine.module_path.as_ref() == machine_path.as_ref() && machine.name == machine_name + })) } -pub fn unique_loaded_machine_elsewhere(machine_name: &str) -> Option { - let source = registry::SourceContext::current()?; - let map = MACHINE_MAP.map().read().ok()?; +pub fn lookup_unique_loaded_machine_by_name( + machine_name: &str, +) -> Result { + lookup_loaded_machine_candidates(loaded_machine_candidates_matching(|machine| { + machine.name == machine_name + })) +} - let mut matches = map - .values() - .filter(|machine| { - machine.name == machine_name - && machine.file_path.as_deref() != Some(source.file_path.as_str()) +pub fn format_loaded_machine_candidates(candidates: &[MachineInfo]) -> String { + candidates + .iter() + .map(|candidate| { + let file_path = candidate.file_path.as_deref().unwrap_or(""); + format!( + "`{}` in `{}` ({file_path}:{})", + candidate.name, candidate.module_path, candidate.line_number + ) }) - .cloned() - .collect::>(); - - matches.sort_by(|left, right| { - left.name - .cmp(&right.name) - .then(left.module_path.as_ref().cmp(right.module_path.as_ref())) - .then(left.file_path.cmp(&right.file_path)) - .then(left.line_number.cmp(&right.line_number)) - }); - matches.dedup_by(|left, right| { - left.name == right.name - && left.module_path.as_ref() == right.module_path.as_ref() - && left.file_path == right.file_path - && left.line_number == right.line_number - }); - - if matches.len() == 1 { - matches.pop() - } else { - None - } + .collect::>() + .join(", ") } pub fn store_machine_struct(machine_info: &MachineInfo) { - MACHINE_MAP.insert(machine_info.module_path.clone(), machine_info.clone()); + upsert_loaded_machine(machine_info); } diff --git a/statum-macros/src/machine/validation.rs b/statum-macros/src/machine/validation.rs index 1a7db62..82ffef5 100644 --- a/statum-macros/src/machine/validation.rs +++ b/statum-macros/src/machine/validation.rs @@ -2,7 +2,9 @@ use proc_macro2::TokenStream; use quote::ToTokens; use syn::{Item, ItemStruct}; -use crate::{ItemTarget, StateModulePath, ensure_state_enum_loaded}; +use crate::{ + ItemTarget, StateModulePath, lookup_loaded_state_enum, lookup_loaded_state_enum_by_name, +}; use super::metadata::is_rust_analyzer; use super::MachineInfo; @@ -39,7 +41,11 @@ pub fn validate_machine_struct(item: &ItemStruct, machine_info: &MachineInfo) -> }; let state_path: StateModulePath = machine_info.module_path.clone(); - let matching_state_enum = ensure_state_enum_loaded(&state_path); + let matching_state_enum = machine_info + .state_generic_name + .as_deref() + .and_then(|state_name| lookup_loaded_state_enum_by_name(&state_path, state_name).ok()) + .or_else(|| lookup_loaded_state_enum(&state_path).ok()); if item.generics.params.len() > 1 { let generics_display = item.generics.to_token_stream().to_string(); diff --git a/statum-macros/src/state.rs b/statum-macros/src/state.rs index edbb64c..db5d640 100644 --- a/statum-macros/src/state.rs +++ b/statum-macros/src/state.rs @@ -1,11 +1,13 @@ -use macro_registry::analysis::{EnumEntry, FileAnalysis}; use macro_registry::callsite::{current_source_info, module_path_for_line}; -use macro_registry::registry; use proc_macro2::TokenStream; use quote::{format_ident, quote, ToTokens}; +use std::sync::{OnceLock, RwLock}; use syn::{Fields, Ident, Item, ItemEnum, Path, Type, Visibility}; -use crate::{ItemTarget, ModulePath, extract_derives}; +use crate::{ + ItemTarget, ModulePath, SourceFingerprint, crate_root_for_file, current_crate_root, + extract_derives, source_file_fingerprint, +}; // Structure to hold extracted enum data #[derive(Clone)] @@ -18,6 +20,9 @@ pub struct EnumInfo { pub generics: String, pub module_path: StateModulePath, pub file_path: Option, + pub crate_root: Option, + pub file_fingerprint: Option, + pub line_number: usize, } impl EnumInfo { @@ -28,16 +33,6 @@ impl EnumInfo { } } -impl registry::RegistryValue for EnumInfo { - fn file_path(&self) -> Option<&str> { - self.file_path.as_deref() - } - - fn set_file_path(&mut self, file_path: String) { - self.file_path = Some(file_path); - } -} - #[derive(Clone)] pub struct VariantInfo { pub name: String, @@ -140,51 +135,109 @@ impl ToTokens for EnumInfo { } } -// Global storage for `#[state]` enums +static LOADED_STATE_ENUMS: OnceLock>> = OnceLock::new(); -static STATE_ENUMS: registry::StaticRegistry = - registry::StaticRegistry::new(); +#[derive(Clone)] +pub enum LoadedStateLookupFailure { + NotFound, + Ambiguous(Vec), +} -struct StateRegistryDomain; +fn loaded_state_enums() -> &'static RwLock> { + LOADED_STATE_ENUMS.get_or_init(|| RwLock::new(Vec::new())) +} -impl registry::RegistryDomain for StateRegistryDomain { - type Key = StateModulePath; - type Value = EnumInfo; - type Entry = EnumEntry; +fn same_loaded_state(left: &EnumInfo, right: &EnumInfo) -> bool { + left.name == right.name + && left.module_path.as_ref() == right.module_path.as_ref() + && left.file_path == right.file_path + && left.line_number == right.line_number +} - fn entries(analysis: &FileAnalysis) -> &[Self::Entry] { - &analysis.enums - } +fn upsert_loaded_state(enum_info: &EnumInfo) { + let Ok(mut states) = loaded_state_enums().write() else { + return; + }; - fn entry_line(entry: &Self::Entry) -> usize { - entry.line_number + if let Some(existing) = states + .iter_mut() + .find(|existing| same_loaded_state(existing, enum_info)) + { + *existing = enum_info.clone(); + } else { + states.push(enum_info.clone()); } +} - fn build_value(entry: &Self::Entry, module_path: &Self::Key) -> Option { - EnumInfo::from_item_enum_with_module(&entry.item, module_path.clone()).ok() - } +fn loaded_state_candidates_matching(matches: F) -> Vec +where + F: Fn(&EnumInfo) -> bool, +{ + let current_crate_root = current_crate_root(); + let Ok(states) = loaded_state_enums().read() else { + return Vec::new(); + }; + + states + .iter() + .filter(|state| loaded_state_is_current(state, current_crate_root.as_deref())) + .filter(|state| matches(state)) + .cloned() + .collect() +} - fn matches_entry(entry: &Self::Entry) -> bool { - entry.attrs.iter().any(|attr| attr == "state") +fn loaded_state_is_current(state: &EnumInfo, current_crate_root: Option<&str>) -> bool { + if current_crate_root.is_some() && state.crate_root.as_deref() != current_crate_root { + return false; } - fn entry_hint(entry: &Self::Entry) -> Option { - Some(entry.item.ident.to_string()) + match (state.file_path.as_deref(), state.file_fingerprint.as_ref()) { + (Some(file_path), Some(fingerprint)) => { + source_file_fingerprint(file_path).as_ref() == Some(fingerprint) + } + _ => true, } } -impl registry::NamedRegistryDomain for StateRegistryDomain { - fn entry_name(entry: &Self::Entry) -> String { - entry.item.ident.to_string() +fn lookup_loaded_state_candidates( + candidates: Vec, +) -> Result { + match candidates.len() { + 0 => Err(LoadedStateLookupFailure::NotFound), + 1 => Ok(candidates.into_iter().next().expect("single candidate")), + _ => Err(LoadedStateLookupFailure::Ambiguous(candidates)), } +} - fn value_name(value: &Self::Value) -> String { - value.name.clone() - } +pub fn lookup_loaded_state_enum( + enum_path: &StateModulePath, +) -> Result { + lookup_loaded_state_candidates(loaded_state_candidates_matching(|state| { + state.module_path.as_ref() == enum_path.as_ref() + })) +} + +pub fn lookup_loaded_state_enum_by_name( + enum_path: &StateModulePath, + enum_name: &str, +) -> Result { + lookup_loaded_state_candidates(loaded_state_candidates_matching(|state| { + state.module_path.as_ref() == enum_path.as_ref() && state.name == enum_name + })) } -pub fn get_state_enum(enum_path: &StateModulePath) -> Option { - STATE_ENUMS.get_cloned(enum_path) +pub fn format_loaded_state_candidates(candidates: &[EnumInfo]) -> String { + candidates + .iter() + .map(|candidate| { + let file_path = candidate.file_path.as_deref().unwrap_or(""); + format!( + "`{}` in `{}` ({file_path}:{})", + candidate.name, candidate.module_path, candidate.line_number + ) + }) + .collect::>() + .join(", ") } pub fn invalid_state_target_error(item: &Item) -> TokenStream { @@ -204,44 +257,6 @@ pub fn invalid_state_target_error(item: &Item) -> TokenStream { syn::Error::new(target.span(), message).to_compile_error() } -pub fn ensure_state_enum_loaded(enum_path: &StateModulePath) -> Option { - registry::ensure_loaded::(&STATE_ENUMS, enum_path) -} - -pub fn ensure_state_enum_loaded_from_source( - enum_path: &StateModulePath, - source: ®istry::SourceContext, -) -> Option { - registry::try_ensure_loaded_from_source::( - &STATE_ENUMS, - registry::LookupMode::from_key(enum_path), - source, - ) - .ok() - .map(|loaded| loaded.value) -} - -pub fn ensure_state_enum_loaded_by_name( - enum_path: &StateModulePath, - enum_name: &str, -) -> Option { - registry::ensure_loaded_by_name::(&STATE_ENUMS, enum_path, enum_name) -} - -pub fn ensure_state_enum_loaded_by_name_from_source( - enum_path: &StateModulePath, - enum_name: &str, - source: ®istry::SourceContext, -) -> Option { - registry::try_ensure_loaded_by_name_from_source::( - &STATE_ENUMS, - registry::LookupMode::from_key(enum_path), - enum_name, - source, - ) - .ok() - .map(|loaded| loaded.value) -} impl EnumInfo { pub fn from_item_enum(item: &ItemEnum) -> syn::Result { let Some((file_path, line_number)) = current_source_info() else { @@ -262,23 +277,37 @@ impl EnumInfo { ), )); }; - Self::from_item_enum_with_module_and_file(item, module_path.into(), Some(file_path)) + Self::from_item_enum_with_module_and_file( + item, + module_path.into(), + Some(file_path), + line_number, + ) } + #[cfg(test)] pub fn from_item_enum_with_module( item: &ItemEnum, module_path: StateModulePath, ) -> syn::Result { let file_path = current_source_info().map(|(path, _)| path); - Self::from_item_enum_with_module_and_file(item, module_path, file_path) + let line_number = current_source_info().map(|(_, line)| line).unwrap_or_default(); + Self::from_item_enum_with_module_and_file(item, module_path, file_path, line_number) } fn from_item_enum_with_module_and_file( item: &ItemEnum, module_path: StateModulePath, file_path: Option, + line_number: usize, ) -> syn::Result { validate_state_enum_shape(item)?; + let crate_root = file_path + .as_deref() + .and_then(crate_root_for_file); + let file_fingerprint = file_path + .as_deref() + .and_then(source_file_fingerprint); let name = item.ident.to_string(); let vis = item.vis.to_token_stream().to_string(); @@ -316,6 +345,9 @@ impl EnumInfo { generics, module_path, file_path, + crate_root, + file_fingerprint, + line_number, }) } } @@ -371,17 +403,7 @@ fn validate_state_enum_shape(item: &ItemEnum) -> syn::Result<()> { Ok(()) } -pub fn generate_state_impls(enum_path: &StateModulePath) -> proc_macro2::TokenStream { - let Some(enum_info) = get_state_enum(enum_path) else { - let message = format!( - "Internal error: state metadata for module `{}` was not cached during code generation.\nEnsure `#[state]` is applied in that module and try re-running `cargo check`.", - enum_path - ); - return quote! { - compile_error!(#message); - }; - }; - +pub fn generate_state_impls(enum_info: &EnumInfo) -> proc_macro2::TokenStream { let state_trait_ident = enum_info.get_trait_name(); let parsed_enum = match enum_info.parse() { Ok(parsed) => parsed, @@ -481,7 +503,7 @@ pub fn validate_state_enum(item: &ItemEnum) -> Option { } pub fn store_state_enum(enum_info: &EnumInfo) { - STATE_ENUMS.insert(enum_info.module_path.clone(), enum_info.clone()); + upsert_loaded_state(enum_info); } #[cfg(test)] diff --git a/statum-macros/src/syntax.rs b/statum-macros/src/syntax.rs index 3df5aff..a4bd345 100644 --- a/statum-macros/src/syntax.rs +++ b/statum-macros/src/syntax.rs @@ -1,10 +1,14 @@ use core::fmt; +use std::fs; +use std::path::{Path as FsPath, PathBuf}; +use std::time::UNIX_EPOCH; +use macro_registry::callsite::current_source_info; use macro_registry::registry; use proc_macro2::{Span, TokenStream}; use quote::{ToTokens, quote}; use syn::spanned::Spanned; -use syn::{Attribute, Item, Path}; +use syn::{Attribute, Item, Path as SynPath}; #[derive(Clone, Debug, Hash, Eq, PartialEq)] pub struct ModulePath(pub String); @@ -54,6 +58,58 @@ impl From for ModulePath { } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SourceFingerprint { + len: u64, + modified_ns: Option, +} + +fn normalize_path(path: &FsPath) -> PathBuf { + if path.is_absolute() { + return path.to_path_buf(); + } + + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) +} + +pub(crate) fn source_file_fingerprint(file_path: &str) -> Option { + let metadata = fs::metadata(normalize_path(FsPath::new(file_path))).ok()?; + let modified_ns = metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()); + + Some(SourceFingerprint { + len: metadata.len(), + modified_ns, + }) +} + +pub(crate) fn crate_root_for_file(file_path: &str) -> Option { + let mut path = normalize_path(FsPath::new(file_path)); + if path.is_file() { + path = path.parent()?.to_path_buf(); + } + + let mut cursor = Some(path.as_path()); + while let Some(dir) = cursor { + if dir.join("Cargo.toml").is_file() { + return Some(dir.to_string_lossy().into_owned()); + } + cursor = dir.parent(); + } + + None +} + +pub(crate) fn current_crate_root() -> Option { + let (file_path, _) = current_source_info()?; + crate_root_for_file(&file_path) +} + pub(crate) fn extract_derives(attr: &Attribute) -> Option> { if !attr.path().is_ident("derive") { return None; @@ -62,7 +118,7 @@ pub(crate) fn extract_derives(attr: &Attribute) -> Option> { attr.meta .require_list() .ok()? - .parse_args_with(syn::punctuated::Punctuated::::parse_terminated) + .parse_args_with(syn::punctuated::Punctuated::::parse_terminated) .ok() .map(|paths| { paths @@ -185,3 +241,74 @@ impl From<&Item> for ItemTarget { } } } + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + use std::thread; + use std::time::Duration; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::{crate_root_for_file, source_file_fingerprint}; + + fn unique_temp_dir(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("statum_syntax_{label}_{nanos}")); + fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent"); + } + fs::write(path, contents).expect("write file"); + } + + #[test] + fn crate_root_for_file_walks_up_to_manifest_dir() { + let crate_dir = unique_temp_dir("crate_root"); + let src = crate_dir.join("src"); + let nested = crate_dir.join("tests").join("ui"); + let lib = src.join("lib.rs"); + let fixture = nested.join("fixture.rs"); + + write_file( + &crate_dir.join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\nedition = \"2024\"\n", + ); + write_file(&lib, "pub fn marker() {}\n"); + write_file(&fixture, "fn main() {}\n"); + + assert_eq!( + crate_root_for_file(&lib.to_string_lossy()).as_deref(), + Some(crate_dir.to_string_lossy().as_ref()) + ); + assert_eq!( + crate_root_for_file(&fixture.to_string_lossy()).as_deref(), + Some(crate_dir.to_string_lossy().as_ref()) + ); + + let _ = fs::remove_dir_all(crate_dir); + } + + #[test] + fn source_file_fingerprint_tracks_file_changes() { + let crate_dir = unique_temp_dir("fingerprint"); + let file = crate_dir.join("src").join("lib.rs"); + write_file(&file, "pub fn marker() {}\n"); + let before = source_file_fingerprint(&file.to_string_lossy()).expect("before fingerprint"); + + thread::sleep(Duration::from_millis(5)); + write_file(&file, "pub fn marker() {}\npub fn changed() {}\n"); + let after = source_file_fingerprint(&file.to_string_lossy()).expect("after fingerprint"); + + assert_ne!(before, after); + + let _ = fs::remove_dir_all(crate_dir); + } +} diff --git a/statum-macros/src/transition.rs b/statum-macros/src/transition.rs index b4fb44a..91bfd2b 100644 --- a/statum-macros/src/transition.rs +++ b/statum-macros/src/transition.rs @@ -436,6 +436,7 @@ pub fn missing_transition_machine_error( 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() @@ -449,6 +450,17 @@ pub fn missing_transition_machine_error( 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!( @@ -461,12 +473,38 @@ pub fn missing_transition_machine_error( format!("A struct named `{machine_name}` exists on line {line}, but it is not annotated with `#[machine]`.") }); let message = format!( - "Error: no `#[machine]` named `{machine_name}` was found in module `{module_path}`.\n{}\n{elsewhere_line}\n{available_line}\nHelp: apply `#[transition]` to an impl for the machine type generated by `#[machine]` in this module.\nCorrect shape: `#[transition] impl {suggested_machine_name} {{ ... }}` where `{suggested_machine_name}` is declared with `#[machine]` in `{module_path}`.", + "Error: no resolved `#[machine]` named `{machine_name}` was found in module `{module_path}`.\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}\nHelp: apply `#[transition]` to an impl for the machine type generated by `#[machine]` in this module and declare that machine before the transition impl.\nCorrect shape: `#[transition] impl {suggested_machine_name} {{ ... }}` 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 = crate::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 = crate::format_loaded_machine_candidates(candidates); + let message = format!( + "Error: `#[transition]` impl for `{machine_name}` in module `{module_path}` could not use include-style fallback because the loaded machine name was ambiguous.\nLoaded `#[machine]` candidates: {candidate_line}.\nHelp: keep the machine name unique within the current crate when using include-generated transition fragments, or move the transition impl next to its machine definition." + ); + compile_error_at(span, &message) +} + fn available_machine_candidates_in_module(module_path: &str) -> Vec { let Some((file_path, _)) = current_source_info() else { return Vec::new(); diff --git a/statum-macros/src/validators/resolution.rs b/statum-macros/src/validators/resolution.rs index d9f67ab..3348115 100644 --- a/statum-macros/src/validators/resolution.rs +++ b/statum-macros/src/validators/resolution.rs @@ -7,8 +7,10 @@ use macro_registry::callsite::current_source_info; use macro_registry::query; use crate::{ - EnumInfo, MachineInfo, MachinePath, StateModulePath, ensure_machine_loaded_by_name, - ensure_state_enum_loaded_by_name, get_state_enum, to_snake_case, + 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; @@ -114,7 +116,8 @@ pub(super) fn resolve_machine_metadata( ) -> Result { let module_path_key: MachinePath = module_path.into(); let machine_name = machine_ident.to_string(); - ensure_machine_loaded_by_name(&module_path_key, &machine_name).ok_or_else(|| { + 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() @@ -128,6 +131,19 @@ pub(super) fn resolve_machine_metadata( 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!( @@ -141,8 +157,17 @@ pub(super) fn resolve_machine_metadata( "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 `#[machine]` named `{machine_name}` was found in module `{module_path}`.\n{}\n{elsewhere_line}\n{available_line}\nHelp: point `#[validators(...)]` at the Statum machine type in this module.\nCorrect shape: `#[validators({suggested_machine_name})] impl PersistedRow {{ ... }}` where `{suggested_machine_name}` is declared with `#[machine]` in `{module_path}`.", + "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! { @@ -158,17 +183,12 @@ pub(super) fn resolve_state_enum_info( 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 _ = if let Some(expected_name) = expected_state_name { - ensure_state_enum_loaded_by_name(&state_path_key, expected_name) - } else { - None - }; - let state_enum_info = match expected_state_name { - Some(expected_name) => ensure_state_enum_loaded_by_name(&state_path_key, expected_name), - None => get_state_enum(&state_path_key), + 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.ok_or_else(|| { + 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() @@ -198,13 +218,38 @@ pub(super) fn resolve_state_enum_info( "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{}\n{elsewhere_line}\n{available_line}\nHelp: make sure the machine's first generic names the right `#[state]` enum in this module.\nCorrect shape: `struct {machine_name} {{ ... }}` where `ExpectedState` is a `#[state]` enum declared in `{module_path}`.", + "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} {{ ... }}` 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! { diff --git a/statum-macros/tests/macro_errors.rs b/statum-macros/tests/macro_errors.rs index 9229d33..1ea51b6 100644 --- a/statum-macros/tests/macro_errors.rs +++ b/statum-macros/tests/macro_errors.rs @@ -19,6 +19,7 @@ fn test_invalid_machine_usage() { t.compile_fail("tests/ui/invalid_machine_private_field_access.rs"); t.compile_fail("tests/ui/invalid_machine_missing_state_derive.rs"); t.compile_fail("tests/ui/invalid_machine_plain_enum_missing_state_attr.rs"); + t.compile_fail("tests/ui/invalid_machine_declared_before_state.rs"); } #[test] @@ -34,6 +35,7 @@ fn test_invalid_transition_usage() { t.compile_fail("tests/ui/invalid_transition_unknown_return_state.rs"); t.compile_fail("tests/ui/invalid_transition_unknown_secondary_return_state.rs"); t.compile_fail("tests/ui/invalid_transition_map_undeclared_edge.rs"); + t.compile_fail("tests/ui/invalid_transition_include_ambiguous_machine_name.rs"); t.compile_fail("tests/ui/invalid_legacy_transition_helper_trait.rs"); } @@ -50,6 +52,7 @@ fn test_invalid_validators_usage() { t.compile_fail("tests/ui/invalid_validators_unknown_machine.rs"); t.compile_fail("tests/ui/invalid_validators_plain_struct_machine_name.rs"); t.compile_fail("tests/ui/invalid_validators_parameter_name_collision.rs"); + t.compile_fail("tests/ui/invalid_validators_declared_before_machine.rs"); t.compile_fail("tests/ui/invalid_legacy_superstate.rs"); t.compile_fail("tests/ui/invalid_legacy_machine_builder.rs"); t.compile_fail("tests/ui/invalid_legacy_machines_builder.rs"); @@ -81,6 +84,7 @@ fn test_valid_macro_usage() { t.pass("tests/ui/valid_machine_field_aliases_local_validators.rs"); t.pass("tests/ui/valid_machine_field_module_paths.rs"); t.pass("tests/ui/valid_machine_field_aliases_renamed_import.rs"); + t.pass("tests/ui/valid_cfg_hidden_duplicate_state_machine.rs"); t.pass("tests/ui/valid_builder_overwrite.rs"); t.pass("tests/ui/valid_helper_trait_visibility.rs"); t.pass("tests/ui/valid_advanced_traits.rs"); diff --git a/statum-macros/tests/ui/invalid_machine_declared_before_state.rs b/statum-macros/tests/ui/invalid_machine_declared_before_state.rs new file mode 100644 index 0000000..d97c5e3 --- /dev/null +++ b/statum-macros/tests/ui/invalid_machine_declared_before_state.rs @@ -0,0 +1,21 @@ +#![allow(unused_imports)] +extern crate self as statum; +pub use statum_core::__private; +pub use statum_core::TransitionInventory; +pub use statum_core::{ + CanTransitionMap, CanTransitionTo, CanTransitionWith, DataState, Error, MachineDescriptor, + MachineGraph, MachineIntrospection, MachineStateIdentity, StateDescriptor, StateMarker, + TransitionDescriptor, UnitState, +}; + +use statum_macros::{machine, state}; + +#[machine] +struct WorkflowMachine {} + +#[state] +enum WorkflowState { + Draft, +} + +fn main() {} diff --git a/statum-macros/tests/ui/invalid_machine_declared_before_state.stderr b/statum-macros/tests/ui/invalid_machine_declared_before_state.stderr new file mode 100644 index 0000000..aba89be --- /dev/null +++ b/statum-macros/tests/ui/invalid_machine_declared_before_state.stderr @@ -0,0 +1,15 @@ +error: Failed to resolve the #[state] enum for machine `WorkflowMachine`. + Expected a `#[state]` enum named `WorkflowState` in module `invalid_machine_declared_before_state`. + Statum only resolves `#[state]` enums that have already expanded before this `#[machine]` declaration. + Source scan found `#[state]` enum `WorkflowState` later in this module on line 17. If that item is active for this build, move it above machine `WorkflowMachine` because Statum resolves these relationships in expansion order. + No plain enum with that expected name was found in that module either. + No same-named `#[state]` enums were found in other modules of this file. + Available `#[state]` enums in that module: `WorkflowState` in `invalid_machine_declared_before_state` (line 17). + Help: make sure the machine's first generic names the right `#[state]` enum in this module and declare that `#[state]` enum before the machine. + Correct shape: `struct WorkflowMachine { ... }` where `ExpectedState` is a `#[state]` enum in `invalid_machine_declared_before_state`. + --> tests/ui/invalid_machine_declared_before_state.rs:13:1 + | +13 | #[machine] + | ^^^^^^^^^^ + | + = note: this error originates in the attribute macro `machine` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/statum-macros/tests/ui/invalid_machine_plain_enum_missing_state_attr.stderr b/statum-macros/tests/ui/invalid_machine_plain_enum_missing_state_attr.stderr index fa497be..242c98f 100644 --- a/statum-macros/tests/ui/invalid_machine_plain_enum_missing_state_attr.stderr +++ b/statum-macros/tests/ui/invalid_machine_plain_enum_missing_state_attr.stderr @@ -1,9 +1,10 @@ error: Failed to resolve the #[state] enum for machine `WorkflowMachine`. Expected a `#[state]` enum named `WorkflowState` in module `invalid_machine_plain_enum_missing_state_attr`. + Statum only resolves `#[state]` enums that have already expanded before this `#[machine]` declaration. An enum named `WorkflowState` exists on line 14, but it is not annotated with `#[state]`. No same-named `#[state]` enums were found in other modules of this file. No `#[state]` enums were found in that module. - Help: make sure the machine's first generic names the right `#[state]` enum in this module. + Help: make sure the machine's first generic names the right `#[state]` enum in this module and declare that `#[state]` enum before the machine. Correct shape: `struct WorkflowMachine { ... }` where `ExpectedState` is a `#[state]` enum in `invalid_machine_plain_enum_missing_state_attr`. --> tests/ui/invalid_machine_plain_enum_missing_state_attr.rs:18:1 | diff --git a/statum-macros/tests/ui/invalid_transition_include_ambiguous_machine_name.rs b/statum-macros/tests/ui/invalid_transition_include_ambiguous_machine_name.rs new file mode 100644 index 0000000..632684c --- /dev/null +++ b/statum-macros/tests/ui/invalid_transition_include_ambiguous_machine_name.rs @@ -0,0 +1,41 @@ +#![allow(unused_imports)] +extern crate self as statum; +pub use statum_core::__private; +pub use statum_core::TransitionInventory; +pub use statum_core::{ + CanTransitionMap, CanTransitionTo, CanTransitionWith, DataState, Error, MachineDescriptor, + MachineGraph, MachineIntrospection, MachineStateIdentity, StateDescriptor, StateMarker, + TransitionDescriptor, UnitState, +}; + +use statum_macros::{machine, state, transition}; + +mod beta { + use super::*; + + #[state] + enum FlowState { + Start, + Done, + } + + #[machine] + struct FlowMachine {} +} + +mod alpha { + use super::*; + + #[state] + enum FlowState { + Start, + Done, + } + + #[machine] + struct FlowMachine {} + + include!("support/ambiguous_transition_include.rs"); +} + +fn main() {} diff --git a/statum-macros/tests/ui/invalid_transition_include_ambiguous_machine_name.stderr b/statum-macros/tests/ui/invalid_transition_include_ambiguous_machine_name.stderr new file mode 100644 index 0000000..27369fb --- /dev/null +++ b/statum-macros/tests/ui/invalid_transition_include_ambiguous_machine_name.stderr @@ -0,0 +1,7 @@ +error: Error: `#[transition]` impl for `FlowMachine` in module `ambiguous_transition_include` could not use include-style fallback because the loaded machine name was ambiguous. + Loaded `#[machine]` candidates: `FlowMachine` in `invalid_transition_include_ambiguous_machine_name::beta` ($DIR/tests/ui/invalid_transition_include_ambiguous_machine_name.rs:22), `FlowMachine` in `invalid_transition_include_ambiguous_machine_name::alpha` ($DIR/tests/ui/invalid_transition_include_ambiguous_machine_name.rs:35). + Help: keep the machine name unique within the current crate when using include-generated transition fragments, or move the transition impl next to its machine definition. + --> tests/ui/support/ambiguous_transition_include.rs + | + | impl FlowMachine { + | ^^^^^^^^^^^ diff --git a/statum-macros/tests/ui/invalid_transition_plain_struct_machine_name.stderr b/statum-macros/tests/ui/invalid_transition_plain_struct_machine_name.stderr index 63ff2f8..9f800fe 100644 --- a/statum-macros/tests/ui/invalid_transition_plain_struct_machine_name.stderr +++ b/statum-macros/tests/ui/invalid_transition_plain_struct_machine_name.stderr @@ -1,8 +1,9 @@ -error: Error: no `#[machine]` named `Machine` was found in module `invalid_transition_plain_struct_machine_name`. +error: Error: no resolved `#[machine]` named `Machine` was found in module `invalid_transition_plain_struct_machine_name`. + Statum 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. A struct named `Machine` exists on line 20, but it is not annotated with `#[machine]`. No same-named `#[machine]` items were found in other modules of this file. No `#[machine]` items were found in this module. - Help: apply `#[transition]` to an impl for the machine type generated by `#[machine]` in this module. + Help: apply `#[transition]` to an impl for the machine type generated by `#[machine]` in this module and declare that machine before the transition impl. Correct shape: `#[transition] impl Machine { ... }` where `Machine` is declared with `#[machine]` in `invalid_transition_plain_struct_machine_name`. --> tests/ui/invalid_transition_plain_struct_machine_name.rs:23:6 | diff --git a/statum-macros/tests/ui/invalid_transition_unknown_machine.stderr b/statum-macros/tests/ui/invalid_transition_unknown_machine.stderr index bcc6086..cf66eb6 100644 --- a/statum-macros/tests/ui/invalid_transition_unknown_machine.stderr +++ b/statum-macros/tests/ui/invalid_transition_unknown_machine.stderr @@ -1,8 +1,9 @@ -error: Error: no `#[machine]` named `DoesNotExist` was found in module `invalid_transition_unknown_machine`. +error: Error: no resolved `#[machine]` named `DoesNotExist` was found in module `invalid_transition_unknown_machine`. + Statum 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. A struct named `DoesNotExist` exists on line 23, but it is not annotated with `#[machine]`. No same-named `#[machine]` items were found in other modules of this file. Available `#[machine]` items in this module: `Machine` in `invalid_transition_unknown_machine` (line 21). - Help: apply `#[transition]` to an impl for the machine type generated by `#[machine]` in this module. + Help: apply `#[transition]` to an impl for the machine type generated by `#[machine]` in this module and declare that machine before the transition impl. Correct shape: `#[transition] impl Machine { ... }` where `Machine` is declared with `#[machine]` in `invalid_transition_unknown_machine`. --> tests/ui/invalid_transition_unknown_machine.rs:26:6 | diff --git a/statum-macros/tests/ui/invalid_validators_declared_before_machine.rs b/statum-macros/tests/ui/invalid_validators_declared_before_machine.rs new file mode 100644 index 0000000..f3fbe2f --- /dev/null +++ b/statum-macros/tests/ui/invalid_validators_declared_before_machine.rs @@ -0,0 +1,36 @@ +#![allow(unused_imports)] +extern crate self as statum; +pub use statum_core::__private; +pub use statum_core::TransitionInventory; +pub use statum_core::{ + CanTransitionMap, CanTransitionTo, CanTransitionWith, DataState, Error, MachineDescriptor, + MachineGraph, MachineIntrospection, MachineStateIdentity, StateDescriptor, StateMarker, + TransitionDescriptor, UnitState, +}; + +use statum_macros::{machine, state, validators}; + +#[state] +enum WorkflowState { + Draft, +} + +struct Row { + status: &'static str, +} + +#[validators(WorkflowMachine)] +impl Row { + fn is_draft(&self) -> Result<(), statum_core::Error> { + if self.status == "draft" { + Ok(()) + } else { + Err(statum_core::Error::InvalidState) + } + } +} + +#[machine] +struct WorkflowMachine {} + +fn main() {} diff --git a/statum-macros/tests/ui/invalid_validators_declared_before_machine.stderr b/statum-macros/tests/ui/invalid_validators_declared_before_machine.stderr new file mode 100644 index 0000000..fc7b74e --- /dev/null +++ b/statum-macros/tests/ui/invalid_validators_declared_before_machine.stderr @@ -0,0 +1,14 @@ +error: Error: no resolved `#[machine]` named `WorkflowMachine` was found in module `invalid_validators_declared_before_machine`. + Statum only resolves `#[machine]` items that have already expanded before this `#[validators]` impl. + Source scan found `#[machine]` item `WorkflowMachine` later in this module on line 34. If that item is active for this build, move it above this `#[validators]` impl because Statum resolves these relationships in expansion order. + No plain struct with that name was found in this module either. + No same-named `#[machine]` items were found in other modules of this file. + Available `#[machine]` items in this module: `WorkflowMachine` in `invalid_validators_declared_before_machine` (line 34). + Help: point `#[validators(...)]` at the Statum machine type in this module and declare that `#[machine]` item before this validators impl. + Correct shape: `#[validators(WorkflowMachine)] impl PersistedRow { ... }` where `WorkflowMachine` is declared with `#[machine]` in `invalid_validators_declared_before_machine`. + --> tests/ui/invalid_validators_declared_before_machine.rs:22:1 + | +22 | #[validators(WorkflowMachine)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `validators` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/statum-macros/tests/ui/invalid_validators_plain_struct_machine_name.stderr b/statum-macros/tests/ui/invalid_validators_plain_struct_machine_name.stderr index e0ad418..391db69 100644 --- a/statum-macros/tests/ui/invalid_validators_plain_struct_machine_name.stderr +++ b/statum-macros/tests/ui/invalid_validators_plain_struct_machine_name.stderr @@ -1,8 +1,9 @@ -error: Error: no `#[machine]` named `TaskMachine` was found in module `invalid_validators_plain_struct_machine_name`. +error: Error: no resolved `#[machine]` named `TaskMachine` was found in module `invalid_validators_plain_struct_machine_name`. + Statum only resolves `#[machine]` items that have already expanded before this `#[validators]` impl. A struct named `TaskMachine` exists on line 19, but it is not annotated with `#[machine]`. No same-named `#[machine]` items were found in other modules of this file. No `#[machine]` items were found in this module. - Help: point `#[validators(...)]` at the Statum machine type in this module. + Help: point `#[validators(...)]` at the Statum machine type in this module and declare that `#[machine]` item before this validators impl. Correct shape: `#[validators(TaskMachine)] impl PersistedRow { ... }` where `TaskMachine` is declared with `#[machine]` in `invalid_validators_plain_struct_machine_name`. --> tests/ui/invalid_validators_plain_struct_machine_name.rs:28:1 | diff --git a/statum-macros/tests/ui/invalid_validators_unknown_machine.stderr b/statum-macros/tests/ui/invalid_validators_unknown_machine.stderr index 6d342b9..e922240 100644 --- a/statum-macros/tests/ui/invalid_validators_unknown_machine.stderr +++ b/statum-macros/tests/ui/invalid_validators_unknown_machine.stderr @@ -1,8 +1,9 @@ -error: Error: no `#[machine]` named `DoesNotExist` was found in module `invalid_validators_unknown_machine`. +error: Error: no resolved `#[machine]` named `DoesNotExist` was found in module `invalid_validators_unknown_machine`. + Statum only resolves `#[machine]` items that have already expanded before this `#[validators]` impl. No plain struct with that name was found in this module either. No same-named `#[machine]` items were found in other modules of this file. Available `#[machine]` items in this module: `TaskMachine` in `invalid_validators_unknown_machine` (line 20). - Help: point `#[validators(...)]` at the Statum machine type in this module. + Help: point `#[validators(...)]` at the Statum machine type in this module and declare that `#[machine]` item before this validators impl. Correct shape: `#[validators(TaskMachine)] impl PersistedRow { ... }` where `TaskMachine` is declared with `#[machine]` in `invalid_validators_unknown_machine`. --> tests/ui/invalid_validators_unknown_machine.rs:28:1 | diff --git a/statum-macros/tests/ui/support/ambiguous_transition_include.rs b/statum-macros/tests/ui/support/ambiguous_transition_include.rs new file mode 100644 index 0000000..6f7e370 --- /dev/null +++ b/statum-macros/tests/ui/support/ambiguous_transition_include.rs @@ -0,0 +1,6 @@ +#[transition] +impl FlowMachine { + fn finish(self) -> FlowMachine { + self.transition() + } +} diff --git a/statum-macros/tests/ui/valid_cfg_hidden_duplicate_state_machine.rs b/statum-macros/tests/ui/valid_cfg_hidden_duplicate_state_machine.rs new file mode 100644 index 0000000..c187f68 --- /dev/null +++ b/statum-macros/tests/ui/valid_cfg_hidden_duplicate_state_machine.rs @@ -0,0 +1,81 @@ +#![allow(unused_imports)] +extern crate self as statum; +pub use statum_core::__private; +pub use statum_core::TransitionInventory; +pub use statum_core::{ + CanTransitionMap, CanTransitionTo, CanTransitionWith, DataState, Error, MachineDescriptor, + MachineGraph, MachineIntrospection, MachineStateIdentity, StateDescriptor, StateMarker, + TransitionDescriptor, UnitState, +}; + +use statum_macros::{machine, state, transition, validators}; + +#[cfg(any())] +#[state] +enum WorkflowState { + Hidden, +} + +#[state] +enum WorkflowState { + Draft, + Done, +} + +#[cfg(any())] +#[machine] +struct WorkflowMachine { + hidden: u8, +} + +#[machine] +struct WorkflowMachine { + name: &'static str, +} + +#[transition] +impl WorkflowMachine { + fn finish(self) -> WorkflowMachine { + self.transition() + } +} + +struct Row { + status: &'static str, +} + +#[validators(WorkflowMachine)] +impl Row { + fn is_draft(&self) -> Result<(), statum_core::Error> { + let _ = &name; + if self.status == "draft" { + Ok(()) + } else { + Err(statum_core::Error::InvalidState) + } + } + + fn is_done(&self) -> Result<(), statum_core::Error> { + let _ = &name; + if self.status == "done" { + Ok(()) + } else { + Err(statum_core::Error::InvalidState) + } + } +} + +fn main() { + let machine = WorkflowMachine::::builder().name("todo").build(); + let _finished = machine.finish(); + + let rebuilt = Row { status: "done" } + .into_machine() + .name("todo") + .build() + .unwrap(); + match rebuilt { + workflow_machine::SomeState::Draft(_) => panic!("expected done"), + workflow_machine::SomeState::Done(machine) => assert_eq!(machine.name, "todo"), + } +} diff --git a/statum/README.md b/statum/README.md index 67cea79..c961b60 100644 --- a/statum/README.md +++ b/statum/README.md @@ -60,6 +60,8 @@ impl Light { self.transition() } } + +# fn main() {} ``` ## Docs diff --git a/statum/src/lib.rs b/statum/src/lib.rs index 8b11308..81382d7 100644 --- a/statum/src/lib.rs +++ b/statum/src/lib.rs @@ -62,6 +62,7 @@ //! let _paid = ready.pay(); //! } //! ``` + //! //! # Typed Rehydration //! @@ -235,6 +236,14 @@ //! - The repository README and `docs/` directory contain longer guides and //! showcase applications. +#[cfg(doctest)] +#[doc = include_str!("../../README.md")] +mod root_readme_doctests {} + +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +mod crate_readme_doctests {} + #[doc(hidden)] pub use statum_core::__private; #[doc(inline)]