Skip to main content

syncbat_macros/
lib.rs

1//! Procedural macros for syncbat operation kits.
2
3use proc_macro::TokenStream;
4use quote::quote;
5use syn::parse::{Parse, ParseStream};
6use syn::punctuated::Punctuated;
7use syn::spanned::Spanned;
8use syn::{
9    parse_macro_input, Error, Expr, ExprLit, FnArg, Ident, ItemFn, Lit, MetaNameValue, Result,
10    Token,
11};
12
13/// Generate a syncbat operation descriptor and optional registration function.
14#[proc_macro_attribute]
15pub fn operation(attr: TokenStream, item: TokenStream) -> TokenStream {
16    let args = parse_macro_input!(attr as OperationArgs);
17    let function = parse_macro_input!(item as ItemFn);
18
19    match expand_operation(args, &function) {
20        Ok(tokens) => tokens.into(),
21        Err(error) => error.to_compile_error().into(),
22    }
23}
24
25struct OperationArgs {
26    pairs: Punctuated<MetaNameValue, Token![,]>,
27}
28
29impl Parse for OperationArgs {
30    fn parse(input: ParseStream<'_>) -> Result<Self> {
31        Ok(Self {
32            pairs: Punctuated::parse_terminated(input)?,
33        })
34    }
35}
36
37struct ParsedOperationArgs {
38    descriptor: Ident,
39    register: Option<Ident>,
40    register_item: Option<Ident>,
41    name: Lit,
42    effect: Ident,
43    input_schema: Lit,
44    output_schema: Lit,
45    receipt_kind: Lit,
46    title: Option<Lit>,
47}
48
49fn expand_operation(args: OperationArgs, function: &ItemFn) -> Result<proc_macro2::TokenStream> {
50    validate_function(function)?;
51    let parsed = parse_args(args)?;
52
53    let fn_name = &function.sig.ident;
54    let descriptor = &parsed.descriptor;
55    let name = &parsed.name;
56    let effect = &parsed.effect;
57    let input_schema = &parsed.input_schema;
58    let output_schema = &parsed.output_schema;
59    let receipt_kind = &parsed.receipt_kind;
60    let descriptor_expr = if let Some(title) = &parsed.title {
61        quote! {
62            ::syncbat::OperationDescriptor::new_with_title(
63                #name,
64                ::syncbat::EffectClass::#effect,
65                #input_schema,
66                #output_schema,
67                #receipt_kind,
68                #title,
69            )
70        }
71    } else {
72        quote! {
73            ::syncbat::OperationDescriptor::new(
74                #name,
75                ::syncbat::EffectClass::#effect,
76                #input_schema,
77                #output_schema,
78                #receipt_kind,
79            )
80        }
81    };
82
83    let register_item_fn = parsed.register_item.as_ref().map(|register_item| {
84        quote! {
85            pub fn #register_item() -> ::syncbat::OperationRegisterItem {
86                ::syncbat::OperationRegisterItem::new(#descriptor.clone(), #fn_name)
87            }
88        }
89    });
90
91    let item_expr = if let Some(register_item) = &parsed.register_item {
92        quote! { #register_item() }
93    } else {
94        quote! { ::syncbat::OperationRegisterItem::new(#descriptor.clone(), #fn_name) }
95    };
96
97    let register_fn = parsed.register.map(|register| {
98        quote! {
99            pub fn #register(
100                builder: &mut ::syncbat::CoreBuilder,
101            ) -> ::std::result::Result<&mut ::syncbat::CoreBuilder, ::syncbat::BuildError> {
102                builder.register_item(#item_expr)
103            }
104        }
105    });
106
107    Ok(quote! {
108        #function
109
110        const #descriptor: ::syncbat::OperationDescriptor = #descriptor_expr;
111
112        const _: fn(&[u8], &mut ::syncbat::Ctx<'_>) -> ::syncbat::HandlerResult = #fn_name;
113
114        #register_item_fn
115
116        #register_fn
117    })
118}
119
120fn validate_function(function: &ItemFn) -> Result<()> {
121    if let Some(asyncness) = &function.sig.asyncness {
122        return Err(Error::new(
123            asyncness.span,
124            "#[syncbat::operation] does not support async functions",
125        ));
126    }
127    if let Some(unsafety) = &function.sig.unsafety {
128        return Err(Error::new(
129            unsafety.span,
130            "#[syncbat::operation] does not support unsafe functions",
131        ));
132    }
133    if let Some(abi) = &function.sig.abi {
134        let is_rust_abi = abi.name.as_ref().is_some_and(|name| name.value() == "Rust");
135        if !is_rust_abi {
136            return Err(Error::new(
137                abi.extern_token.span,
138                "#[syncbat::operation] only supports Rust ABI functions",
139            ));
140        }
141    }
142    if !function.sig.generics.params.is_empty() || function.sig.generics.where_clause.is_some() {
143        return Err(Error::new(
144            function.sig.generics.span(),
145            "#[syncbat::operation] does not support generic functions",
146        ));
147    }
148
149    if function.sig.inputs.len() != 2 {
150        return Err(Error::new(
151            function.sig.inputs.span(),
152            "#[syncbat::operation] handlers must accept `&[u8]` and `&mut syncbat::Ctx<'_>`",
153        ));
154    }
155    if function
156        .sig
157        .inputs
158        .iter()
159        .any(|arg| matches!(arg, FnArg::Receiver(_)))
160    {
161        return Err(Error::new(
162            function.sig.inputs.span(),
163            "#[syncbat::operation] handlers must be free functions",
164        ));
165    }
166
167    Ok(())
168}
169
170fn parse_args(args: OperationArgs) -> Result<ParsedOperationArgs> {
171    let mut descriptor = None;
172    let mut register = None;
173    let mut register_item = None;
174    let mut name = None;
175    let mut effect = None;
176    let mut input_schema = None;
177    let mut output_schema = None;
178    let mut receipt_kind = None;
179    let mut title = None;
180
181    for pair in args.pairs {
182        let key = pair
183            .path
184            .get_ident()
185            .ok_or_else(|| Error::new(pair.path.span(), "expected operation attribute key"))?
186            .to_string();
187        match key.as_str() {
188            "descriptor" => set_ident(&mut descriptor, "descriptor", &pair)?,
189            "register" => set_ident(&mut register, "register", &pair)?,
190            "register_item" => set_ident(&mut register_item, "register_item", &pair)?,
191            "name" => set_string(&mut name, "name", &pair)?,
192            "effect" => set_effect(&mut effect, &pair)?,
193            "input_schema" => set_string(&mut input_schema, "input_schema", &pair)?,
194            "output_schema" => set_string(&mut output_schema, "output_schema", &pair)?,
195            "receipt_kind" => set_string(&mut receipt_kind, "receipt_kind", &pair)?,
196            "title" => set_string(&mut title, "title", &pair)?,
197            other => {
198                return Err(Error::new(
199                    pair.path.span(),
200                    format!("unknown key `{other}` in #[syncbat::operation]"),
201                ));
202            }
203        }
204    }
205
206    Ok(ParsedOperationArgs {
207        descriptor: required(descriptor, "descriptor")?,
208        register,
209        register_item,
210        name: required(name, "name")?,
211        effect: required(effect, "effect")?,
212        input_schema: required(input_schema, "input_schema")?,
213        output_schema: required(output_schema, "output_schema")?,
214        receipt_kind: required(receipt_kind, "receipt_kind")?,
215        title,
216    })
217}
218
219fn set_ident(target: &mut Option<Ident>, key: &str, pair: &MetaNameValue) -> Result<()> {
220    if target.is_some() {
221        return Err(Error::new(
222            pair.path.span(),
223            format!("duplicate `{key}` key in #[syncbat::operation]"),
224        ));
225    }
226    if let Expr::Path(path) = &pair.value {
227        if path.path.segments.len() == 1 && path.path.get_ident().is_some() {
228            *target = path.path.get_ident().cloned();
229            return Ok(());
230        }
231    }
232    Err(Error::new(
233        pair.value.span(),
234        format!("`{key}` must be a Rust identifier"),
235    ))
236}
237
238fn set_string(target: &mut Option<Lit>, key: &str, pair: &MetaNameValue) -> Result<()> {
239    if target.is_some() {
240        return Err(Error::new(
241            pair.path.span(),
242            format!("duplicate `{key}` key in #[syncbat::operation]"),
243        ));
244    }
245    match string_lit(&pair.value) {
246        Some(lit) => {
247            *target = Some(lit);
248            Ok(())
249        }
250        None => Err(Error::new(
251            pair.value.span(),
252            format!("`{key}` must be a string literal"),
253        )),
254    }
255}
256
257fn set_effect(target: &mut Option<Ident>, pair: &MetaNameValue) -> Result<()> {
258    if target.is_some() {
259        return Err(Error::new(
260            pair.path.span(),
261            "duplicate `effect` key in #[syncbat::operation]",
262        ));
263    }
264    if let Expr::Path(path) = &pair.value {
265        if path.path.segments.len() == 1 {
266            if let Some(ident) = path.path.get_ident() {
267                return match ident.to_string().as_str() {
268                    "Inspect" | "Compute" | "Persist" | "Emit" | "Control" => {
269                        *target = Some(ident.clone());
270                        Ok(())
271                    }
272                    other => Err(Error::new(
273                        ident.span(),
274                        format!("unsupported effect `{other}` in #[syncbat::operation]"),
275                    )),
276                };
277            }
278        }
279    }
280    Err(Error::new(
281        pair.value.span(),
282        "`effect` must be a syncbat EffectClass variant identifier",
283    ))
284}
285
286fn string_lit(expr: &Expr) -> Option<Lit> {
287    if let Expr::Lit(ExprLit {
288        lit: lit @ Lit::Str(_),
289        ..
290    }) = expr
291    {
292        Some(lit.clone())
293    } else {
294        None
295    }
296}
297
298fn required<T>(value: Option<T>, key: &str) -> Result<T> {
299    value.ok_or_else(|| {
300        Error::new(
301            proc_macro2::Span::call_site(),
302            format!("#[syncbat::operation] requires `{key} = ...`"),
303        )
304    })
305}