Skip to main content

zynk_macros/
lib.rs

1//! Proc macros for registering Rust Zynk endpoints.
2//!
3//! The attribute macros in this crate preserve the user's function unchanged
4//! and add link-time `EndpointMeta` registrations consumed by server bindings.
5
6use proc_macro::TokenStream;
7use quote::{format_ident, quote};
8use syn::{
9    parse::Parser, parse_macro_input, punctuated::Punctuated, spanned::Spanned, Expr, ExprLit,
10    FnArg, GenericArgument, ItemFn, Lit, Meta, PathArguments, ReturnType, Token, Type,
11};
12
13#[proc_macro_attribute]
14pub fn command(_attr: TokenStream, item: TokenStream) -> TokenStream {
15    let input = parse_macro_input!(item as ItemFn);
16    expand_endpoint(input, EndpointSurface::Command)
17        .unwrap_or_else(syn::Error::into_compile_error)
18        .into()
19}
20
21#[proc_macro_attribute]
22pub fn message(_attr: TokenStream, item: TokenStream) -> TokenStream {
23    let input = parse_macro_input!(item as ItemFn);
24    expand_endpoint(input, EndpointSurface::Message)
25        .unwrap_or_else(syn::Error::into_compile_error)
26        .into()
27}
28
29#[proc_macro_attribute]
30pub fn upload(attr: TokenStream, item: TokenStream) -> TokenStream {
31    let config = match parse_upload_config(attr) {
32        Ok(config) => config,
33        Err(error) => return error.into_compile_error().into(),
34    };
35    let input = parse_macro_input!(item as ItemFn);
36    expand_endpoint(input, EndpointSurface::Upload(config))
37        .unwrap_or_else(syn::Error::into_compile_error)
38        .into()
39}
40
41#[proc_macro_attribute]
42pub fn static_file(_attr: TokenStream, item: TokenStream) -> TokenStream {
43    let input = parse_macro_input!(item as ItemFn);
44    expand_endpoint(input, EndpointSurface::StaticFile)
45        .unwrap_or_else(syn::Error::into_compile_error)
46        .into()
47}
48
49#[derive(Default)]
50struct UploadConfig {
51    max_size: Option<u64>,
52    allowed_types: Vec<String>,
53}
54
55enum EndpointSurface {
56    Command,
57    Message,
58    Upload(UploadConfig),
59    StaticFile,
60}
61
62struct UploadParts {
63    params: Vec<ParamTokens>,
64    file_param: String,
65    multi_file: bool,
66}
67
68fn expand_endpoint(
69    function: ItemFn,
70    surface: EndpointSurface,
71) -> syn::Result<proc_macro2::TokenStream> {
72    reject_methods(&function)?;
73
74    let name = function.sig.ident.to_string();
75    let params_ident = format_ident!(
76        "__ZYNK_{}_PARAMS",
77        function.sig.ident.to_string().to_uppercase()
78    );
79    let returns = return_type_from_signature(&function.sig.output);
80
81    let registration = match surface {
82        EndpointSurface::Command => {
83            let params = params_from_signature(&function)?;
84            let params_tokens = params.iter().map(ParamTokens::to_tokens);
85
86            quote! {
87                #[allow(non_upper_case_globals)]
88                const #params_ident: &[::zynk_runtime::ParamMeta] = &[#(#params_tokens),*];
89
90                ::zynk_runtime::inventory::submit! {
91                    ::zynk_runtime::EndpointMeta {
92                        name: #name,
93                        kind: ::zynk_runtime::EndpointKind::Rpc,
94                        module: Some(module_path!()),
95                        doc: None,
96                        params: #params_ident,
97                        returns: #returns,
98                        channel_item: None,
99                        file_param: None,
100                        multi_file: false,
101                        max_size: None,
102                        allowed_types: &[],
103                        server_events: &[],
104                        client_events: &[],
105                        handler_key: Some(::zynk_runtime::HandlerKey(concat!(module_path!(), "::", #name))),
106                    }
107                }
108            }
109        }
110        EndpointSurface::Message => {
111            let params = params_from_signature(&function)?;
112            let params_tokens = params.iter().map(ParamTokens::to_tokens);
113
114            quote! {
115                #[allow(non_upper_case_globals)]
116                const #params_ident: &[::zynk_runtime::ParamMeta] = &[#(#params_tokens),*];
117
118                ::zynk_runtime::inventory::submit! {
119                    ::zynk_runtime::EndpointMeta {
120                        name: #name,
121                        kind: ::zynk_runtime::EndpointKind::Ws,
122                        module: Some(module_path!()),
123                        doc: None,
124                        params: &[],
125                        returns: #returns,
126                        channel_item: None,
127                        file_param: None,
128                        multi_file: false,
129                        max_size: None,
130                        allowed_types: &[],
131                        server_events: #params_ident,
132                        client_events: #params_ident,
133                        handler_key: Some(::zynk_runtime::HandlerKey(concat!(module_path!(), "::", #name))),
134                    }
135                }
136            }
137        }
138        EndpointSurface::Upload(config) => {
139            validate_upload_async(&function)?;
140            let upload = upload_parts_from_signature(&function)?;
141            let params_tokens = upload.params.iter().map(ParamTokens::to_tokens);
142            let file_param = upload.file_param;
143            let multi_file = upload.multi_file;
144            let max_size = option_u64_tokens(config.max_size);
145            let allowed_types = config.allowed_types.iter();
146
147            quote! {
148                #[allow(non_upper_case_globals)]
149                const #params_ident: &[::zynk_runtime::ParamMeta] = &[#(#params_tokens),*];
150
151                ::zynk_runtime::inventory::submit! {
152                    ::zynk_runtime::EndpointMeta {
153                        name: #name,
154                        kind: ::zynk_runtime::EndpointKind::Upload,
155                        module: Some(module_path!()),
156                        doc: None,
157                        params: #params_ident,
158                        returns: #returns,
159                        channel_item: None,
160                        file_param: Some(#file_param),
161                        multi_file: #multi_file,
162                        max_size: #max_size,
163                        allowed_types: &[#(#allowed_types),*],
164                        server_events: &[],
165                        client_events: &[],
166                        handler_key: Some(::zynk_runtime::HandlerKey(concat!(module_path!(), "::", #name))),
167                    }
168                }
169            }
170        }
171        EndpointSurface::StaticFile => {
172            validate_static_file_return(&function.sig.output)?;
173            let params = params_from_signature(&function)?;
174            let params_tokens = params.iter().map(ParamTokens::to_tokens);
175
176            quote! {
177                #[allow(non_upper_case_globals)]
178                const #params_ident: &[::zynk_runtime::ParamMeta] = &[#(#params_tokens),*];
179
180                ::zynk_runtime::inventory::submit! {
181                    ::zynk_runtime::EndpointMeta {
182                        name: #name,
183                        kind: ::zynk_runtime::EndpointKind::Static,
184                        module: Some(module_path!()),
185                        doc: None,
186                        params: #params_ident,
187                        returns: #returns,
188                        channel_item: None,
189                        file_param: None,
190                        multi_file: false,
191                        max_size: None,
192                        allowed_types: &[],
193                        server_events: &[],
194                        client_events: &[],
195                        handler_key: Some(::zynk_runtime::HandlerKey(concat!(module_path!(), "::", #name))),
196                    }
197                }
198            }
199        }
200    };
201
202    Ok(quote! {
203        #function
204        #registration
205    })
206}
207
208fn parse_upload_config(attr: TokenStream) -> syn::Result<UploadConfig> {
209    let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
210    let metas = parser.parse(attr)?;
211    let mut config = UploadConfig::default();
212
213    for meta in metas {
214        match meta {
215            Meta::NameValue(name_value) if name_value.path.is_ident("max_size") => {
216                let Expr::Lit(expr_lit) = &name_value.value else {
217                    return Err(syn::Error::new_spanned(
218                        name_value.value,
219                        "max_size must be a string literal like \"10MB\"",
220                    ));
221                };
222                let Lit::Str(size) = &expr_lit.lit else {
223                    return Err(syn::Error::new_spanned(
224                        &expr_lit.lit,
225                        "max_size must be a string literal like \"10MB\"",
226                    ));
227                };
228                config.max_size = Some(parse_size_literal(size)?);
229            }
230            Meta::NameValue(name_value) if name_value.path.is_ident("allowed_types") => {
231                let Expr::Array(array) = &name_value.value else {
232                    return Err(syn::Error::new_spanned(
233                        name_value.value,
234                        "allowed_types must be an array of string literals",
235                    ));
236                };
237                config.allowed_types = parse_allowed_types(array)?;
238            }
239            other => {
240                return Err(syn::Error::new_spanned(
241                    other,
242                    "unsupported #[zynk::upload] option; expected max_size = \"10MB\" or allowed_types = [\"image/*\"]",
243                ));
244            }
245        }
246    }
247
248    Ok(config)
249}
250
251fn parse_size_literal(lit: &syn::LitStr) -> syn::Result<u64> {
252    let raw = lit.value();
253    let compact: String = raw.chars().filter(|ch| !ch.is_whitespace()).collect();
254    let digit_len = compact
255        .char_indices()
256        .take_while(|(_, ch)| ch.is_ascii_digit() || *ch == '_')
257        .map(|(idx, ch)| idx + ch.len_utf8())
258        .last()
259        .unwrap_or(0);
260
261    if digit_len == 0 {
262        return Err(syn::Error::new(
263            lit.span(),
264            "max_size must start with an integer byte count",
265        ));
266    }
267
268    let number_text = compact[..digit_len].replace('_', "");
269    let number = number_text.parse::<u64>().map_err(|error| {
270        syn::Error::new(
271            lit.span(),
272            format!("max_size integer value is invalid: {error}"),
273        )
274    })?;
275    let suffix = compact[digit_len..].to_ascii_uppercase();
276    let factor = match suffix.as_str() {
277        "" | "B" => 1,
278        "K" | "KB" | "KIB" => 1024,
279        "M" | "MB" | "MIB" => 1024_u64.pow(2),
280        "G" | "GB" | "GIB" => 1024_u64.pow(3),
281        "T" | "TB" | "TIB" => 1024_u64.pow(4),
282        _ => {
283            return Err(syn::Error::new(
284                lit.span(),
285                "max_size unit must be one of B, KB, MB, GB, or TB",
286            ));
287        }
288    };
289
290    number.checked_mul(factor).ok_or_else(|| {
291        syn::Error::new(
292            lit.span(),
293            "max_size overflows the supported u64 byte count",
294        )
295    })
296}
297
298fn parse_allowed_types(array: &syn::ExprArray) -> syn::Result<Vec<String>> {
299    array
300        .elems
301        .iter()
302        .map(|expr| {
303            let Expr::Lit(ExprLit {
304                lit: Lit::Str(value),
305                ..
306            }) = expr
307            else {
308                return Err(syn::Error::new_spanned(
309                    expr,
310                    "allowed_types entries must be string literals",
311                ));
312            };
313            Ok(value.value())
314        })
315        .collect()
316}
317
318fn option_u64_tokens(value: Option<u64>) -> proc_macro2::TokenStream {
319    match value {
320        Some(value) => quote! { Some(#value) },
321        None => quote! { None },
322    }
323}
324
325fn reject_methods(function: &ItemFn) -> syn::Result<()> {
326    for input in &function.sig.inputs {
327        if matches!(input, FnArg::Receiver(_)) {
328            return Err(syn::Error::new(
329                input.span(),
330                "#[zynk::command] and #[zynk::message] can only be applied to free functions; methods with self receivers are not supported",
331            ));
332        }
333    }
334    Ok(())
335}
336
337fn validate_upload_async(function: &ItemFn) -> syn::Result<()> {
338    if function.sig.asyncness.is_some() {
339        Ok(())
340    } else {
341        Err(syn::Error::new(
342            function.sig.ident.span(),
343            "#[zynk::upload] handlers must be async functions",
344        ))
345    }
346}
347
348struct ParamTokens {
349    source_name: String,
350    wire_name: String,
351    ty: proc_macro2::TokenStream,
352    required: bool,
353}
354
355impl ParamTokens {
356    fn to_tokens(&self) -> proc_macro2::TokenStream {
357        let source_name = &self.source_name;
358        let wire_name = &self.wire_name;
359        let ty = &self.ty;
360        let required = self.required;
361        quote! {
362            ::zynk_runtime::ParamMeta {
363                source_name: #source_name,
364                wire_name: #wire_name,
365                ty: #ty,
366                required: #required,
367                default: None,
368            }
369        }
370    }
371}
372
373fn params_from_signature(function: &ItemFn) -> syn::Result<Vec<ParamTokens>> {
374    function.sig.inputs.iter().map(param_from_fn_arg).collect()
375}
376
377fn upload_parts_from_signature(function: &ItemFn) -> syn::Result<UploadParts> {
378    let mut params = Vec::new();
379    let mut file_param = None;
380
381    for input in &function.sig.inputs {
382        let (ident, ty) = typed_param_ident_and_type(input)?;
383        if let Some(multi_file) = upload_file_type(ty) {
384            if file_param.is_some() {
385                return Err(syn::Error::new(
386                    input.span(),
387                    "#[zynk::upload] supports exactly one UploadFile or Vec<UploadFile> parameter",
388                ));
389            }
390            file_param = Some((ident.to_string(), multi_file));
391        } else {
392            params.push(param_from_ident_and_type(ident, ty));
393        }
394    }
395
396    let Some((file_param, multi_file)) = file_param else {
397        return Err(syn::Error::new(
398            function.sig.ident.span(),
399            "#[zynk::upload] requires one UploadFile or Vec<UploadFile> parameter",
400        ));
401    };
402
403    Ok(UploadParts {
404        params,
405        file_param,
406        multi_file,
407    })
408}
409
410fn param_from_fn_arg(input: &FnArg) -> syn::Result<ParamTokens> {
411    let (ident, ty) = typed_param_ident_and_type(input)?;
412    Ok(param_from_ident_and_type(ident, ty))
413}
414
415fn typed_param_ident_and_type(input: &FnArg) -> syn::Result<(&syn::Ident, &Type)> {
416    match input {
417        FnArg::Typed(argument) => {
418            let ident = match argument.pat.as_ref() {
419                syn::Pat::Ident(pat_ident) => &pat_ident.ident,
420                other => {
421                    return Err(syn::Error::new(
422                        other.span(),
423                        "Zynk endpoint parameters must be simple identifiers like `user_id: i64`",
424                    ));
425                }
426            };
427            Ok((ident, &argument.ty))
428        }
429        FnArg::Receiver(receiver) => Err(syn::Error::new(
430            receiver.span(),
431            "Zynk endpoint macros do not support self receivers",
432        )),
433    }
434}
435
436fn param_from_ident_and_type(ident: &syn::Ident, ty: &Type) -> ParamTokens {
437    let lowered = lower_type(ty);
438    ParamTokens {
439        source_name: ident.to_string(),
440        wire_name: to_camel_case(&ident.to_string()),
441        ty: lowered.tokens,
442        required: !lowered.optional,
443    }
444}
445
446fn return_type_from_signature(output: &ReturnType) -> proc_macro2::TokenStream {
447    match output {
448        ReturnType::Default => quote! { ::zynk_runtime::TypeRefStatic::void() },
449        ReturnType::Type(_, ty) => lower_type(ty).tokens,
450    }
451}
452
453fn validate_static_file_return(output: &ReturnType) -> syn::Result<()> {
454    let ReturnType::Type(_, ty) = output else {
455        return Err(syn::Error::new_spanned(
456            output,
457            "#[zynk::static_file] functions must return StaticFile",
458        ));
459    };
460
461    if is_named_type(ty, "StaticFile") {
462        Ok(())
463    } else {
464        Err(syn::Error::new_spanned(
465            ty,
466            "#[zynk::static_file] functions must return StaticFile",
467        ))
468    }
469}
470
471struct LoweredType {
472    tokens: proc_macro2::TokenStream,
473    optional: bool,
474}
475
476fn lower_type(ty: &Type) -> LoweredType {
477    match ty {
478        Type::Tuple(tuple) if tuple.elems.is_empty() => LoweredType {
479            tokens: quote! { ::zynk_runtime::TypeRefStatic::void() },
480            optional: false,
481        },
482        Type::Path(type_path) => lower_path_type(type_path),
483        Type::Reference(reference) => lower_type(&reference.elem),
484        _ => LoweredType {
485            tokens: quote! { ::zynk_runtime::TypeRefStatic::any() },
486            optional: false,
487        },
488    }
489}
490
491fn lower_path_type(type_path: &syn::TypePath) -> LoweredType {
492    let Some(segment) = type_path.path.segments.last() else {
493        return LoweredType {
494            tokens: quote! { ::zynk_runtime::TypeRefStatic::any() },
495            optional: false,
496        };
497    };
498
499    let ident = segment.ident.to_string();
500    match ident.as_str() {
501        "String" | "str" => primitive("string"),
502        "bool" => primitive("boolean"),
503        "f32" | "f64" => primitive("number"),
504        "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
505        | "usize" => primitive("number"),
506        "Option" => lower_option(segment),
507        "Vec" => lower_vec(segment),
508        "Result" => lower_result(segment),
509        "Value" => LoweredType {
510            tokens: quote! { ::zynk_runtime::TypeRefStatic::any() },
511            optional: false,
512        },
513        other => {
514            let model_name = other.to_string();
515            LoweredType {
516                tokens: quote! { ::zynk_runtime::TypeRefStatic::model(#model_name) },
517                optional: false,
518            }
519        }
520    }
521}
522
523fn primitive(name: &'static str) -> LoweredType {
524    LoweredType {
525        tokens: quote! { ::zynk_runtime::TypeRefStatic::primitive(#name) },
526        optional: false,
527    }
528}
529
530fn lower_option(segment: &syn::PathSegment) -> LoweredType {
531    let inner = first_generic_type(segment)
532        .map(lower_type)
533        .unwrap_or_else(|| LoweredType {
534            tokens: quote! { ::zynk_runtime::TypeRefStatic::any() },
535            optional: false,
536        });
537    let tokens = inner.tokens;
538    LoweredType {
539        tokens: quote! { #tokens.optional().nullable() },
540        optional: true,
541    }
542}
543
544fn lower_vec(segment: &syn::PathSegment) -> LoweredType {
545    let inner = first_generic_type(segment)
546        .map(lower_type)
547        .unwrap_or_else(|| LoweredType {
548            tokens: quote! { ::zynk_runtime::TypeRefStatic::any() },
549            optional: false,
550        });
551    let inner_tokens = inner.tokens;
552    LoweredType {
553        tokens: quote! { ::zynk_runtime::TypeRefStatic::array(&[#inner_tokens]) },
554        optional: false,
555    }
556}
557
558fn lower_result(segment: &syn::PathSegment) -> LoweredType {
559    first_generic_type(segment)
560        .map(lower_type)
561        .unwrap_or_else(|| LoweredType {
562            tokens: quote! { ::zynk_runtime::TypeRefStatic::any() },
563            optional: false,
564        })
565}
566
567fn upload_file_type(ty: &Type) -> Option<bool> {
568    match ty {
569        Type::Reference(reference) => upload_file_type(&reference.elem),
570        Type::Path(type_path) => {
571            let segment = type_path.path.segments.last()?;
572            if segment.ident == "UploadFile" {
573                Some(false)
574            } else if segment.ident == "Vec" {
575                first_generic_type(segment)
576                    .filter(|inner| is_named_type(inner, "UploadFile"))
577                    .map(|_| true)
578            } else {
579                None
580            }
581        }
582        _ => None,
583    }
584}
585
586fn is_named_type(ty: &Type, expected: &str) -> bool {
587    match ty {
588        Type::Reference(reference) => is_named_type(&reference.elem, expected),
589        Type::Path(type_path) => type_path
590            .path
591            .segments
592            .last()
593            .is_some_and(|segment| segment.ident == expected),
594        _ => false,
595    }
596}
597
598fn first_generic_type(segment: &syn::PathSegment) -> Option<&Type> {
599    let PathArguments::AngleBracketed(arguments) = &segment.arguments else {
600        return None;
601    };
602
603    arguments.args.iter().find_map(|argument| match argument {
604        GenericArgument::Type(ty) => Some(ty),
605        _ => None,
606    })
607}
608
609fn to_camel_case(name: &str) -> String {
610    let mut parts = name.split('_');
611    let Some(first) = parts.next() else {
612        return String::new();
613    };
614
615    let mut output = first.to_string();
616    for part in parts {
617        let mut chars = part.chars();
618        if let Some(first_char) = chars.next() {
619            output.extend(first_char.to_uppercase());
620            output.push_str(chars.as_str());
621        }
622    }
623    output
624}