Skip to main content

statum_macros/
lib.rs

1//! Proc-macro implementation crate for Statum.
2//!
3//! Most users should depend on [`statum`](https://docs.rs/statum), which
4//! re-exports these macros with the public-facing documentation. This crate
5//! exists so the macro expansion logic can stay separate from the stable runtime
6//! traits in `statum-core`.
7//!
8//! The public macros are:
9//!
10//! - [`state`] for declaring legal lifecycle phases
11//! - [`machine`] for declaring the typed machine and durable context
12//! - [`transition`] for validating legal transition impls
13//! - [`validators`] for rebuilding typed machines from persisted data
14
15#[cfg(doctest)]
16#[doc = include_str!("../README.md")]
17mod readme_doctests {}
18
19mod analysis;
20mod cache;
21mod callsite;
22mod diagnostics;
23mod module_path;
24mod parser;
25mod pathing;
26mod query;
27mod syntax;
28
29moddef::moddef!(
30    flat (pub) mod {
31    },
32    flat (pub(crate)) mod {
33        presentation,
34        state,
35        machine,
36        transition,
37        validators
38    }
39);
40
41pub(crate) use presentation::{
42    PresentationAttr, PresentationTypesAttr, parse_present_attrs, parse_presentation_types_attr,
43    strip_present_attrs,
44};
45pub(crate) use syntax::{
46    ItemTarget, ModulePath, SourceFingerprint, crate_root_for_file, current_crate_root,
47    extract_derives, source_file_fingerprint,
48};
49
50use crate::callsite::{current_module_path_opt, module_path_for_span};
51use crate::diagnostics::DiagnosticMessage;
52use crate::{
53    LoadedMachineLookupFailure, MachinePath, ambiguous_transition_machine_error,
54    ambiguous_transition_machine_fallback_error, lookup_loaded_machine_in_module,
55    lookup_unique_loaded_machine_by_name,
56};
57use proc_macro::TokenStream;
58use proc_macro2::Span;
59use syn::spanned::Spanned;
60use syn::{Item, ItemImpl, parse_macro_input};
61
62pub(crate) fn strict_introspection_enabled() -> bool {
63    cfg!(feature = "strict-introspection")
64}
65
66/// Define the legal lifecycle phases for a Statum machine.
67///
68/// Apply `#[state]` to an enum with unit variants, single-field tuple
69/// variants, or named-field variants. Statum generates one marker type per
70/// variant plus the state-family traits used by `#[machine]`, `#[transition]`,
71/// and `#[validators]`.
72#[proc_macro_attribute]
73pub fn state(attr: TokenStream, item: TokenStream) -> TokenStream {
74    if !attr.is_empty() {
75        return syn::Error::new(
76            Span::call_site(),
77            DiagnosticMessage::new("`#[state]` does not accept arguments.")
78                .found(format!("`#[state({attr})]`"))
79                .expected("`#[state] enum WorkflowState { Draft, Review(ReviewData) }`")
80                .fix("remove the attribute arguments and describe states with enum variants instead.".to_string())
81                .render(),
82        )
83        .to_compile_error()
84        .into();
85    }
86    let input = parse_macro_input!(item as Item);
87    let input = match input {
88        Item::Enum(item_enum) => item_enum,
89        other => return invalid_state_target_error(&other).into(),
90    };
91
92    // Validate the enum before proceeding
93    if let Some(error) = validate_state_enum(&input) {
94        return error.into();
95    }
96
97    let enum_info = match EnumInfo::from_item_enum(&input) {
98        Ok(info) => info,
99        Err(err) => return err.to_compile_error().into(),
100    };
101
102    // Store metadata in `state_enum_map`
103    store_state_enum(&enum_info);
104
105    // Generate structs and implementations dynamically
106    let expanded = generate_state_impls(&enum_info);
107
108    TokenStream::from(expanded)
109}
110
111/// Define a typed machine that carries durable context across states.
112///
113/// Apply `#[machine]` to a struct whose first generic parameter is the
114/// `#[state]` enum family. Additional type and const generics are supported
115/// after that state generic. Statum generates the typed machine surface,
116/// builders, the machine-scoped `machine::SomeState` enum, a compatibility
117/// alias `machine::State = machine::SomeState`, and helper items such as
118/// `machine::Fields` for heterogeneous batch rebuilds.
119#[proc_macro_attribute]
120pub fn machine(attr: TokenStream, item: TokenStream) -> TokenStream {
121    if !attr.is_empty() {
122        return syn::Error::new(
123            Span::call_site(),
124            DiagnosticMessage::new("`#[machine]` does not accept arguments.")
125                .found(format!("`#[machine({attr})]`"))
126                .expected("`#[machine] struct WorkflowMachine<WorkflowState> { ... }`")
127                .fix("remove the attribute arguments and link the machine to `#[state]` through its first generic parameter.".to_string())
128                .render(),
129        )
130        .to_compile_error()
131        .into();
132    }
133    let input = parse_macro_input!(item as Item);
134    let input = match input {
135        Item::Struct(item_struct) => item_struct,
136        other => return invalid_machine_target_error(&other).into(),
137    };
138    let machine_info = match MachineInfo::from_item_struct(&input) {
139        Ok(info) => info,
140        Err(err) => return err.to_compile_error().into(),
141    };
142
143    // Validate the struct before proceeding
144    if let Some(error) = validate_machine_struct(&input, &machine_info) {
145        return error.into();
146    }
147
148    // Store metadata in `machine_map`
149    store_machine_struct(&machine_info);
150
151    // Generate any required structs or implementations dynamically
152    let expanded = generate_machine_impls(&machine_info, &input);
153
154    TokenStream::from(expanded)
155}
156
157/// Validate and generate legal transitions for one source state.
158///
159/// Apply `#[transition]` to an `impl Machine<CurrentState>` block. Each method
160/// must consume `self` and return a legal `Machine<NextState>` shape or a
161/// source-declared type alias that expands to that shape, or a supported
162/// wrapper around it, such as `Result<Machine<NextState>, E>`,
163/// `Option<Machine<NextState>>`, or
164/// `statum::Branch<Machine<Left>, Machine<Right>>`.
165///
166/// When the `strict-introspection` feature is enabled, transition graph
167/// semantics must be directly readable from the written return type or from a
168/// local `#[introspect(return = ...)]` escape hatch on the method.
169#[proc_macro_attribute]
170pub fn transition(
171    attr: proc_macro::TokenStream,
172    item: proc_macro::TokenStream,
173) -> proc_macro::TokenStream {
174    if !attr.is_empty() {
175        return syn::Error::new(
176            Span::call_site(),
177            DiagnosticMessage::new("`#[transition]` does not accept arguments.")
178                .found(format!("`#[transition({attr})]`"))
179                .expected("`#[transition] impl WorkflowMachine<Draft> { ... }`")
180                .fix("remove the attribute arguments and declare transition behavior with methods inside the impl block.".to_string())
181                .render(),
182        )
183        .to_compile_error()
184        .into();
185    }
186    let input = parse_macro_input!(item as ItemImpl);
187
188    // -- Step 1: Parse
189    let tr_impl = match parse_transition_impl(&input) {
190        Ok(parsed) => parsed,
191        Err(err) => return err.into(),
192    };
193
194    let module_path = match resolved_current_module_path(tr_impl.machine_span, "#[transition]") {
195        Ok(path) => path,
196        Err(err) => return err,
197    };
198
199    let machine_path: MachinePath = module_path.clone().into();
200    let machine_info_owned =
201        match lookup_loaded_machine_in_module(&machine_path, &tr_impl.machine_name) {
202            Ok(info) => Some(info),
203            Err(LoadedMachineLookupFailure::Ambiguous(candidates)) => {
204                return ambiguous_transition_machine_error(
205                    &tr_impl.machine_name,
206                    &module_path,
207                    &candidates,
208                    tr_impl.machine_span,
209                )
210                .into();
211            }
212            Err(LoadedMachineLookupFailure::NotFound) => {
213                match lookup_unique_loaded_machine_by_name(&tr_impl.machine_name) {
214                    Ok(info) => Some(info),
215                    Err(LoadedMachineLookupFailure::Ambiguous(candidates)) => {
216                        return ambiguous_transition_machine_fallback_error(
217                            &tr_impl.machine_name,
218                            &module_path,
219                            &candidates,
220                            tr_impl.machine_span,
221                        )
222                        .into();
223                    }
224                    Err(LoadedMachineLookupFailure::NotFound) => None,
225                }
226            }
227        };
228    let machine_info = match machine_info_owned.as_ref() {
229        Some(info) => info,
230        None => {
231            return missing_transition_machine_error(
232                &tr_impl.machine_name,
233                &module_path,
234                tr_impl.machine_span,
235            )
236            .into();
237        }
238    };
239
240    if let Some(err) = validate_transition_functions(&tr_impl, machine_info) {
241        return err.into();
242    }
243
244    // -- Step 3: Generate new code
245    let expanded = generate_transition_impl(&input, &tr_impl, machine_info);
246
247    // Combine expanded code with the original `impl` if needed
248    // or simply return the expanded code
249    expanded.into()
250}
251
252/// Rebuild typed machines from persisted data.
253///
254/// Apply `#[validators(Machine)]` or an anchored path such as
255/// `#[validators(self::path::Machine)]`,
256/// `#[validators(super::path::Machine)]`, or
257/// `#[validators(crate::path::Machine)]` to an `impl PersistedRow` block.
258/// Statum expects one `is_{state}` method per state variant and generates
259/// `into_machine()`, `.into_machines()`, and `.into_machines_by(...)` helpers
260/// for typed rehydration. Validator methods can return `Result<T, _>` for
261/// ordinary membership checks or `Validation<T>` when rebuild reports should
262/// carry stable rejection details through `.build_report()` and
263/// `.build_reports()`. In relaxed mode, bare multi-segment paths like
264/// `#[validators(flow::Machine)]` are treated as local child-module paths, not
265/// imported aliases or re-exports. If Statum cannot resolve that local path,
266/// it emits a compile error asking for an anchored path instead.
267#[proc_macro_attribute]
268pub fn validators(attr: TokenStream, item: TokenStream) -> TokenStream {
269    if attr.is_empty() {
270        return syn::Error::new(
271            Span::call_site(),
272            DiagnosticMessage::new("`#[validators(...)]` requires a machine path.")
273                .expected("`#[validators(WorkflowMachine)] impl PersistedRow { ... }`")
274                .fix("pass the target Statum machine type in the attribute, for example `#[validators(self::flow::WorkflowMachine)]`.".to_string())
275                .render(),
276        )
277        .to_compile_error()
278        .into();
279    }
280    let item_impl = parse_macro_input!(item as ItemImpl);
281    let module_path = match resolved_current_module_path(item_impl.self_ty.span(), "#[validators]")
282    {
283        Ok(path) => path,
284        Err(err) => return err,
285    };
286    parse_validators(attr, item_impl, &module_path)
287}
288
289fn resolved_current_module_path(span: Span, macro_name: &str) -> Result<String, TokenStream> {
290    let resolved = module_path_for_span(span)
291        .or_else(current_module_path_opt)
292        .or_else(|| {
293            crate::callsite::source_info_for_span(span)
294                .is_none()
295                .then_some("crate".to_string())
296        });
297
298    resolved.ok_or_else(|| {
299        let message = format!(
300            "Internal error: could not resolve the module path for `{macro_name}` at this call site."
301        );
302        quote::quote_spanned! { span =>
303            compile_error!(#message);
304        }
305        .into()
306    })
307}