1use 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#[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}