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