1#![allow(dead_code)]
2
3use proc_macro2::*;
12
13use std::collections::HashMap;
14
15fn token_is_group(tt: TokenTree) -> Option<Group> {
16 match tt {
17 TokenTree::Group(grp) => Some(grp),
18 _ => None,
19 }
20}
21
22fn token_is_literal(tt: TokenTree) -> Option<Literal> {
23 match tt {
24 TokenTree::Literal(l) => Some(l),
25 _ => None,
26 }
27}
28
29fn token_is_ident(tt: TokenTree) -> Option<Ident> {
30 match tt {
31 TokenTree::Ident(id) => Some(id),
32 _ => None,
33 }
34}
35
36fn next_token_is_group(iter: &mut token_stream::IntoIter) -> Option<Group> {
37 iter.next().and_then(token_is_group)
38}
39
40fn next_token_is_ident(iter: &mut token_stream::IntoIter) -> Option<Ident> {
41 iter.next().and_then(token_is_ident)
42}
43
44fn next_token_is_literal(iter: &mut token_stream::IntoIter) -> Option<Literal> {
45 iter.next().and_then(token_is_literal)
46}
47
48fn add_prefix(attr: TokenStream, ident: Ident) -> Ident {
49 let mut tokens = Vec::new();
50 for t in attr {
51 let l = match t {
52 TokenTree::Ident(id) => id.to_string(),
53 TokenTree::Literal(l) => {
54 let s: syn::LitStr =
55 syn::parse(TokenStream::from(TokenTree::from(l)).into()).expect("invalid prefix literal");
56 s.value()
57 }
58 TokenTree::Punct(_) => continue,
59 TokenTree::Group(g) => {
60 let mut group_result = String::new();
62 for token in g.stream().into_iter() {
63 match token {
64 TokenTree::Ident(id) => group_result.push_str(&id.to_string()),
65 TokenTree::Punct(p) => group_result.push(p.as_char()),
66 TokenTree::Literal(l) => group_result.push_str(&l.to_string()),
67 TokenTree::Group(_) => panic!("nested groups not supported"),
68 }
69 }
70 group_result
71 }
72 };
73 tokens.push(l);
74 }
75 syn::Ident::new(
76 &lyquor_primitives::encode_method_name(
77 &tokens[0..tokens.len() - 1].join("_"),
78 &tokens[tokens.len() - 1],
79 &ident.to_string(),
80 ),
81 Span::call_site(),
82 )
83}
84
85struct ParsedFunctionCommon {
86 ctx_ident: syn::Ident,
87 ctx_mut: bool,
88 params: Vec<(syn::Ident, syn::Type)>,
89 attrs: Vec<syn::Attribute>,
90 fn_name: syn::Ident,
91 body: Box<syn::Block>,
92 output: syn::ReturnType,
93}
94
95struct ParsedFunction {
96 ctx_ident: syn::Ident,
97 ctx_mut: bool,
98 params: Vec<(syn::Ident, syn::Type)>,
99 ret_inner: syn::Type,
100 attrs: Vec<syn::Attribute>,
101 fn_name: syn::Ident,
102 body: Box<syn::Block>,
103}
104
105struct ParsedConstructor {
106 ctx_ident: syn::Ident,
107 ctx_mut: bool,
108 params: Vec<(syn::Ident, syn::Type)>,
109 attrs: Vec<syn::Attribute>,
110 fn_name: syn::Ident,
111 body: Box<syn::Block>,
112}
113
114struct HttpExportAttr {
115 method: String,
116 path_prefix: String,
117}
118
119enum ExportKind {
120 Ethereum,
121 Http(HttpExportAttr),
122}
123
124struct MethodAttr {
125 group: Option<syn::Path>,
126 export: Option<ExportKind>,
127}
128
129fn parse_function_common(func: syn::ItemFn) -> syn::Result<ParsedFunctionCommon> {
130 let syn::ItemFn { attrs, sig, block, .. } = func;
131 if sig.asyncness.is_some() {
132 return Err(syn::Error::new_spanned(
133 sig.fn_token,
134 "async functions are not supported",
135 ));
136 }
137 if sig.constness.is_some() {
138 return Err(syn::Error::new_spanned(
139 sig.fn_token,
140 "const functions are not supported",
141 ));
142 }
143 if sig.abi.is_some() {
144 return Err(syn::Error::new_spanned(
145 sig.fn_token,
146 "extern functions are not supported",
147 ));
148 }
149 if sig.variadic.is_some() {
150 return Err(syn::Error::new_spanned(
151 sig.fn_token,
152 "variadic functions are not supported",
153 ));
154 }
155 if !sig.generics.params.is_empty() || sig.generics.where_clause.is_some() {
156 return Err(syn::Error::new_spanned(
157 sig.generics,
158 "generic functions are not supported",
159 ));
160 }
161
162 let mut inputs = sig.inputs.iter();
163 let ctx_arg = inputs
164 .next()
165 .ok_or_else(|| syn::Error::new_spanned(sig.fn_token, "expected a context parameter like `ctx: &mut _`"))?;
166
167 let (ctx_ident, ctx_mut) = match ctx_arg {
168 syn::FnArg::Receiver(receiver) => {
169 return Err(syn::Error::new_spanned(
170 receiver,
171 "method receivers are not supported; use `ctx: &mut _` or `ctx: &_`",
172 ));
173 }
174 syn::FnArg::Typed(pat_type) => {
175 let ctx_ident = match &*pat_type.pat {
176 syn::Pat::Ident(ident) => ident.ident.clone(),
177 _ => {
178 return Err(syn::Error::new_spanned(
179 &pat_type.pat,
180 "context parameter must be an identifier like `ctx`",
181 ));
182 }
183 };
184 if ctx_ident == "_" {
185 return Err(syn::Error::new_spanned(
186 &pat_type.pat,
187 "context parameter must be a named identifier",
188 ));
189 }
190 let ctx_ref = match &*pat_type.ty {
191 syn::Type::Reference(reference) => reference,
192 _ => {
193 return Err(syn::Error::new_spanned(
194 &pat_type.ty,
195 "context parameter must be a reference: `ctx: &mut _` or `ctx: &_`",
196 ));
197 }
198 };
199 (ctx_ident, ctx_ref.mutability.is_some())
200 }
201 };
202
203 let mut params = Vec::new();
204 for arg in inputs {
205 let pat_type = match arg {
206 syn::FnArg::Typed(pat_type) => pat_type,
207 syn::FnArg::Receiver(receiver) => {
208 return Err(syn::Error::new_spanned(
209 receiver,
210 "method receivers are not supported; use `ctx: &mut _` or `ctx: &_`",
211 ));
212 }
213 };
214 let ident = match &*pat_type.pat {
215 syn::Pat::Ident(ident) => ident.ident.clone(),
216 _ => {
217 return Err(syn::Error::new_spanned(
218 &pat_type.pat,
219 "parameter must be an identifier like `name: Type`",
220 ));
221 }
222 };
223 if ident == "_" {
224 return Err(syn::Error::new_spanned(
225 &pat_type.pat,
226 "parameters must be named identifiers",
227 ));
228 }
229 params.push((ident, (*pat_type.ty).clone()));
230 }
231
232 Ok(ParsedFunctionCommon {
233 ctx_ident,
234 ctx_mut,
235 params,
236 attrs,
237 fn_name: sig.ident,
238 body: block,
239 output: sig.output,
240 })
241}
242
243fn parse_function_signature(func: syn::ItemFn) -> syn::Result<ParsedFunction> {
244 let ParsedFunctionCommon {
245 ctx_ident,
246 ctx_mut,
247 params,
248 attrs,
249 fn_name,
250 body,
251 output,
252 } = parse_function_common(func)?;
253
254 let ret_inner = match &output {
255 syn::ReturnType::Type(_, ty) => {
256 let ty_path = match &**ty {
257 syn::Type::Path(path) => path,
258 _ => {
259 return Err(syn::Error::new_spanned(&output, "return type must be LyquidResult<T>"));
260 }
261 };
262 let segment = ty_path
263 .path
264 .segments
265 .last()
266 .ok_or_else(|| syn::Error::new_spanned(&output, "return type must be LyquidResult<T>"))?;
267 if segment.ident != "LyquidResult" {
268 return Err(syn::Error::new_spanned(segment, "return type must be LyquidResult<T>"));
269 }
270 match &segment.arguments {
271 syn::PathArguments::AngleBracketed(args) => {
272 let mut iter = args.args.iter();
273 let inner = match iter.next() {
274 Some(syn::GenericArgument::Type(inner)) => inner.clone(),
275 _ => {
276 return Err(syn::Error::new_spanned(
277 &segment.arguments,
278 "return type must be LyquidResult<T>",
279 ));
280 }
281 };
282 if iter.next().is_some() {
283 return Err(syn::Error::new_spanned(
284 &segment.arguments,
285 "return type must be LyquidResult<T>",
286 ));
287 }
288 inner
289 }
290 _ => {
291 return Err(syn::Error::new_spanned(
292 &segment.arguments,
293 "return type must be LyquidResult<T>",
294 ));
295 }
296 }
297 }
298 syn::ReturnType::Default => {
299 return Err(syn::Error::new_spanned(&output, "return type must be LyquidResult<T>"));
300 }
301 };
302
303 Ok(ParsedFunction {
304 ctx_ident,
305 ctx_mut,
306 params,
307 ret_inner,
308 attrs,
309 fn_name,
310 body,
311 })
312}
313
314fn parse_constructor_signature(func: syn::ItemFn) -> syn::Result<ParsedConstructor> {
315 let ParsedFunctionCommon {
316 ctx_ident,
317 ctx_mut,
318 params,
319 attrs,
320 fn_name,
321 body,
322 output,
323 } = parse_function_common(func)?;
324
325 if fn_name != "constructor" {
326 return Err(syn::Error::new_spanned(
327 fn_name,
328 "constructor function must be named `constructor`",
329 ));
330 }
331
332 if !matches!(output, syn::ReturnType::Default) {
333 return Err(syn::Error::new_spanned(
334 output,
335 "constructor must not specify a return type",
336 ));
337 }
338
339 Ok(ParsedConstructor {
340 ctx_ident,
341 ctx_mut,
342 params,
343 attrs,
344 fn_name,
345 body,
346 })
347}
348
349fn expand_network_function(attr: TokenStream, func: syn::ItemFn) -> syn::Result<TokenStream> {
351 if func.sig.ident == "constructor" {
352 let parsed_attr = parse_method_attr(attr, "lyquid::method::network")?;
353 if parsed_attr.group.is_some() {
354 return Err(syn::Error::new_spanned(
355 func.sig.ident,
356 "constructor does not accept group arguments",
357 ));
358 }
359 if matches!(parsed_attr.export.as_ref(), Some(ExportKind::Http(_))) {
360 return Err(syn::Error::new_spanned(
361 func.sig.ident.clone(),
362 "`export = http` is only supported on #[lyquid::method::instance]",
363 ));
364 }
365 let parsed = parse_constructor_signature(func)?;
366 return expand_constructor(parsed, parsed_attr.export);
367 }
368
369 let MethodAttr {
371 group: group_path,
372 export,
373 } = parse_method_attr(attr, "lyquid::method::network")?;
374 if matches!(export.as_ref(), Some(ExportKind::Http(_))) {
375 return Err(syn::Error::new_spanned(
376 func.sig.ident.clone(),
377 "`export = http` is only supported on #[lyquid::method::instance]",
378 ));
379 }
380 let parsed = parse_function_signature(func)?;
381 let ctx_ident = parsed.ctx_ident;
382 let ctx_mut = parsed.ctx_mut;
383 let params = parsed.params;
384 let ret_inner = parsed.ret_inner;
385 let attrs = parsed.attrs;
386 let fn_name = parsed.fn_name;
387 let body = parsed.body;
388
389 let group_tokens = match group_path.as_ref() {
390 Some(path) => quote::quote!(#path),
391 None => quote::quote!(main),
392 };
393 let ctx_pattern = if ctx_mut {
394 quote::quote! { &mut #ctx_ident }
395 } else {
396 quote::quote! { & #ctx_ident }
397 };
398 let params_ts = params.iter().map(|(ident, ty)| quote::quote! { #ident: #ty });
399 let export_flag = if matches!(export.as_ref(), Some(ExportKind::Ethereum)) {
400 quote::quote! { true }
401 } else {
402 quote::quote! { false }
403 };
404
405 let export_tokens = export
406 .map(|kind| {
407 export_metadata(ExportMetadata {
408 kind,
409 is_network: true,
410 group: group_path.as_ref(),
411 fn_name: &fn_name,
412 ctx_mut,
413 params: ¶ms,
414 ret_inner: &ret_inner,
415 })
416 })
417 .transpose()?
418 .unwrap_or_else(TokenStream::new);
419
420 Ok(quote::quote! {
421 #(#attrs)*
422 lyquid::__lyquid_categorize_methods!(
423 { network(#group_tokens) export(#export_flag) fn #fn_name(#ctx_pattern #(, #params_ts)*) -> LyquidResult<#ret_inner> #body },
424 {},
425 {},
426 {}
427 );
428 #export_tokens
429 })
430}
431
432fn expand_instance_function(attr: TokenStream, func: syn::ItemFn) -> syn::Result<TokenStream> {
434 match parse_instance_attr(attr)? {
435 InstanceAttr::Standard(MethodAttr {
436 group: group_path,
437 export,
438 }) => {
439 let parsed = parse_function_signature(func)?;
440 let ctx_ident = parsed.ctx_ident;
441 let ctx_mut = parsed.ctx_mut;
442 let params = parsed.params;
443 let ret_inner = parsed.ret_inner;
444 let attrs = parsed.attrs;
445 let fn_name = parsed.fn_name;
446 let body = parsed.body;
447
448 let group_tokens = match group_path.as_ref() {
449 Some(path) => quote::quote!(#path),
450 None => quote::quote!(main),
451 };
452 if let Some(path) = group_path.as_ref() &&
454 let Some(oracle_name) = oracle_two_phase_name(path) &&
455 fn_name == "aggregate"
456 {
457 if export.is_some() {
458 return Err(syn::Error::new_spanned(
459 fn_name,
460 "oracle two-phase aggregate does not support `export`",
461 ));
462 }
463 if ctx_mut {
464 return Err(syn::Error::new_spanned(
465 fn_name,
466 "oracle two-phase aggregate must take `ctx: &_`",
467 ));
468 }
469 if !params.is_empty() {
470 return Err(syn::Error::new_spanned(
471 fn_name,
472 "oracle two-phase aggregate must not take extra parameters",
473 ));
474 }
475 if !is_option_certified_call_params(&ret_inner) {
476 return Err(syn::Error::new_spanned(
477 ret_inner,
478 "oracle two-phase aggregate must return LyquidResult<Option<CertifiedCallParams>>",
479 ));
480 }
481 return Ok(quote::quote! {
482 #(#attrs)*
483 lyquid::__lyquid_categorize_methods!(
484 { instance(oracle::two_phase::#oracle_name) export(false) fn aggregate(&#ctx_ident) -> LyquidResult<Option<CertifiedCallParams>> #body },
485 {},
486 {},
487 {}
488 );
489 });
490 }
491 let ctx_pattern = if ctx_mut {
492 quote::quote! { &mut #ctx_ident }
493 } else {
494 quote::quote! { & #ctx_ident }
495 };
496 let params_ts = params.iter().map(|(ident, ty)| quote::quote! { #ident: #ty });
497 let export_flag = if matches!(export.as_ref(), Some(ExportKind::Ethereum)) {
498 quote::quote! { true }
499 } else {
500 quote::quote! { false }
501 };
502
503 let export_tokens = export
504 .map(|kind| {
505 export_metadata(ExportMetadata {
506 kind,
507 is_network: false,
508 group: group_path.as_ref(),
509 fn_name: &fn_name,
510 ctx_mut,
511 params: ¶ms,
512 ret_inner: &ret_inner,
513 })
514 })
515 .transpose()?
516 .unwrap_or_else(TokenStream::new);
517
518 Ok(quote::quote! {
519 #(#attrs)*
520 lyquid::__lyquid_categorize_methods!(
521 { instance(#group_tokens) export(#export_flag) fn #fn_name(#ctx_pattern #(, #params_ts)*) -> LyquidResult<#ret_inner> #body },
522 {},
523 {},
524 {}
525 );
526 #export_tokens
527 })
528 }
529 InstanceAttr::Upc(upc_path) => expand_instance_upc_function(upc_path, func),
530 }
531}
532
533fn expand_constructor(parsed: ParsedConstructor, export: Option<ExportKind>) -> syn::Result<TokenStream> {
535 let ParsedConstructor {
536 ctx_ident,
537 ctx_mut,
538 params,
539 attrs,
540 fn_name: _,
541 body,
542 } = parsed;
543 let ctor_name = quote::format_ident!("__lyquid_constructor");
544 let ctx_init = if ctx_mut {
545 quote::quote! { let mut #ctx_ident = __lyquid::NetworkContext::new(ctx)?; }
546 } else {
547 quote::quote! { let #ctx_ident = __lyquid::ImmutableNetworkContext::new(ctx)?; }
548 };
549 let mutable_flag = if ctx_mut {
550 quote::quote! { true }
551 } else {
552 quote::quote! { false }
553 };
554 let export_flag = if export.is_some() {
555 quote::quote! { true }
556 } else {
557 quote::quote! { false }
558 };
559 let params_ts = params.iter().map(|(ident, ty)| quote::quote! { #ident: #ty });
560 let ctor_ret_inner = syn::parse_quote!(bool);
561
562 let export_tokens = export
563 .map(|kind| {
564 export_metadata(ExportMetadata {
565 kind,
566 is_network: true,
567 group: None,
568 fn_name: &ctor_name,
569 ctx_mut,
570 params: ¶ms,
571 ret_inner: &ctor_ret_inner,
572 })
573 })
574 .transpose()?
575 .unwrap_or_else(TokenStream::new);
576
577 Ok(quote::quote! {
578 #(#attrs)*
579 lyquid::__lyquid_wrap_methods!(
580 "__lyquid_method_network",
581 main (#mutable_flag, #export_flag) fn #ctor_name(#(#params_ts),*) -> LyquidResult<bool> {
582 |ctx: lyquid::CallContext| -> LyquidResult<bool> {
583 use crate::__lyquid;
584 #ctx_init
585 let result: LyquidResult<bool> = (|| -> LyquidResult<bool> { #body; Ok(true) })();
586 drop(#ctx_ident);
587 result
588 }
589 }
590 );
591 #export_tokens
592 })
593}
594
595enum InstanceAttr {
596 Standard(MethodAttr),
597 Upc(syn::Path),
598}
599
600fn expand_instance_upc_function(upc_path: syn::Path, func: syn::ItemFn) -> syn::Result<TokenStream> {
602 let parsed = parse_function_signature(func)?;
603 let ctx_ident = parsed.ctx_ident;
604 let ctx_mut = parsed.ctx_mut;
605 let params = parsed.params;
606 let ret_inner = parsed.ret_inner;
607 let attrs = parsed.attrs;
608 let fn_name = parsed.fn_name;
609 let body = parsed.body;
610
611 let ctx_pattern = if ctx_mut {
612 quote::quote! { &mut #ctx_ident }
613 } else {
614 quote::quote! { & #ctx_ident }
615 };
616 let is_response = upc_path
617 .segments
618 .first()
619 .map(|seg| seg.ident == "response")
620 .unwrap_or(false);
621
622 let (params_ts, ret_tokens): (Vec<TokenStream>, TokenStream) = if is_response {
623 if ctx_mut {
624 return Err(syn::Error::new_spanned(ctx_ident, "upc(response) must take `ctx: &_`"));
625 }
626 if params.len() != 1 {
627 return Err(syn::Error::new_spanned(
628 fn_name,
629 "upc(response) must take exactly one parameter: `response: LyquidResult<T>`",
630 ));
631 }
632
633 let inner = match option_inner_type(&ret_inner) {
634 Some(inner) => inner,
635 None => {
636 return Err(syn::Error::new_spanned(
637 ret_inner,
638 "upc(response) must return LyquidResult<Option<T>>",
639 ));
640 }
641 };
642
643 let (returned_ident, _returned_ty) = ¶ms[0];
644 let params_ts = vec![quote::quote! { #returned_ident: LyquidResult<#inner> }];
645 let ret_tokens = quote::quote! { LyquidResult<Option<#inner>> };
646 (params_ts, ret_tokens)
647 } else {
648 let params_ts = params.iter().map(|(ident, ty)| quote::quote! { #ident: #ty }).collect();
649 let ret_tokens = quote::quote! { LyquidResult<#ret_inner> };
650 (params_ts, ret_tokens)
651 };
652
653 Ok(quote::quote! {
654 #(#attrs)*
655 lyquid::__lyquid_categorize_methods!(
656 { instance(upc::#upc_path) fn #fn_name(#ctx_pattern #(, #params_ts)*) -> #ret_tokens #body },
657 {},
658 {},
659 {}
660 );
661 })
662}
663
664fn option_inner_type(ty: &syn::Type) -> Option<syn::Type> {
665 let path = match ty {
666 syn::Type::Path(path) => path,
667 _ => return None,
668 };
669 let segment = path.path.segments.last()?;
670 if segment.ident != "Option" {
671 return None;
672 }
673 match &segment.arguments {
674 syn::PathArguments::AngleBracketed(args) => {
675 let mut iter = args.args.iter();
676 match iter.next() {
677 Some(syn::GenericArgument::Type(inner)) if iter.next().is_none() => Some(inner.clone()),
678 _ => None,
679 }
680 }
681 _ => None,
682 }
683}
684
685fn parse_instance_attr(attr: TokenStream) -> syn::Result<InstanceAttr> {
687 if attr.is_empty() {
688 return Ok(InstanceAttr::Standard(MethodAttr {
689 group: None,
690 export: None,
691 }));
692 }
693
694 let mut iter = attr.clone().into_iter();
695 let first = iter
696 .next()
697 .ok_or_else(|| syn::Error::new_spanned(&attr, "invalid attribute arguments"))?;
698
699 match first {
700 TokenTree::Ident(ident) if ident == "upc" => {
701 let group = match iter.next() {
702 Some(TokenTree::Group(group)) if group.delimiter() == Delimiter::Parenthesis => group,
703 Some(other) => {
704 return Err(syn::Error::new_spanned(
705 other,
706 "expected `upc(<role>)` for #[lyquid::method::instance]",
707 ));
708 }
709 None => {
710 return Err(syn::Error::new_spanned(
711 ident,
712 "expected `upc(<role>)` for #[lyquid::method::instance]",
713 ));
714 }
715 };
716 if iter.next().is_some() {
717 return Err(syn::Error::new_spanned(
718 attr,
719 "unexpected extra arguments for #[lyquid::method::instance]",
720 ));
721 }
722
723 let upc_path: syn::Path = syn::parse2(group.stream())?;
724 validate_group_path(&upc_path)?;
725 Ok(InstanceAttr::Upc(upc_path))
726 }
727 _ => {
728 let parsed = parse_method_attr(attr, "lyquid::method::instance")?;
729 Ok(InstanceAttr::Standard(parsed))
730 }
731 }
732}
733
734fn parse_method_attr(attr: TokenStream, attr_name: &str) -> syn::Result<MethodAttr> {
735 if attr.is_empty() {
736 return Ok(MethodAttr {
737 group: None,
738 export: None,
739 });
740 }
741
742 let parser = |input: syn::parse::ParseStream| -> syn::Result<MethodAttr> {
743 let mut group = None;
744 let mut export_kind = None::<String>;
745 let mut http_method = None;
746 let mut http_path_prefix = None;
747
748 while !input.is_empty() {
749 let key: syn::Ident = input.parse()?;
750 input.parse::<syn::Token![=]>()?;
751
752 if key == "group" {
753 let path: syn::Path = input.parse()?;
754 validate_group_path(&path)?;
755 if group.is_some() {
756 return Err(syn::Error::new_spanned(key, "duplicate `group` argument"));
757 }
758 group = Some(path);
759 } else if key == "export" {
760 let kind = if input.peek(syn::Ident) {
761 let ident: syn::Ident = input.parse()?;
762 ident.to_string()
763 } else if input.peek(syn::LitStr) {
764 let lit: syn::LitStr = input.parse()?;
765 lit.value()
766 } else {
767 return Err(syn::Error::new_spanned(
768 key,
769 "expected `export = eth` for #[lyquid::method::network/instance]",
770 ));
771 };
772 if export_kind.is_some() {
773 return Err(syn::Error::new_spanned(key, "duplicate `export` argument"));
774 }
775 match kind.as_str() {
776 "eth" | "http" => export_kind = Some(kind),
777 _ => {
778 return Err(syn::Error::new_spanned(
779 key,
780 "unsupported export kind; expected `eth` or `http`",
781 ))
782 }
783 };
784 } else if key == "method" {
785 let lit: syn::LitStr = input.parse()?;
786 if http_method.is_some() {
787 return Err(syn::Error::new_spanned(key, "duplicate `method` argument"));
788 }
789 http_method = Some(validate_http_export_method(lit)?);
790 } else if key == "path_prefix" {
791 let lit: syn::LitStr = input.parse()?;
792 if http_path_prefix.is_some() {
793 return Err(syn::Error::new_spanned(key, "duplicate `path_prefix` argument"));
794 }
795 http_path_prefix = Some(canonical_http_path_prefix(lit)?);
796 } else {
797 return Err(syn::Error::new_spanned(
798 key,
799 format!(
800 "expected `group = foo::bar`, `export = eth`, or `export = http, method = \"GET\", path_prefix = \"/api\"` for #[{attr_name}]"
801 ),
802 ));
803 }
804
805 if input.peek(syn::Token![,]) {
806 input.parse::<syn::Token![,]>()?;
807 }
808 }
809
810 if !matches!(export_kind.as_deref(), Some("http")) && (http_method.is_some() || http_path_prefix.is_some()) {
811 return Err(syn::Error::new(
812 Span::call_site(),
813 "`method` and `path_prefix` are only valid with `export = http`",
814 ));
815 }
816
817 let export = match export_kind.as_deref() {
818 Some("http") => {
819 let method = http_method
820 .ok_or_else(|| syn::Error::new(Span::call_site(), "`export = http` requires `method = \"...\"`"))?;
821 let path_prefix = http_path_prefix.ok_or_else(|| {
822 syn::Error::new(Span::call_site(), "`export = http` requires `path_prefix = \"/...\"`")
823 })?;
824 Some(ExportKind::Http(HttpExportAttr { method, path_prefix }))
825 }
826 Some("eth") => Some(ExportKind::Ethereum),
827 None => None,
828 _ => unreachable!("unsupported export kind should be rejected while parsing"),
829 };
830
831 Ok(MethodAttr { group, export })
832 };
833
834 syn::parse::Parser::parse2(&parser, attr)
835}
836
837fn validate_http_export_method(lit: syn::LitStr) -> syn::Result<String> {
838 let method = lit.value();
839 match method.as_str() {
840 "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "*" => Ok(method),
841 _ => Err(syn::Error::new_spanned(
842 lit,
843 "unsupported HTTP export method; expected GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, or *",
844 )),
845 }
846}
847
848fn canonical_http_path_prefix(lit: syn::LitStr) -> syn::Result<String> {
849 let prefix = lit.value();
850 if !prefix.starts_with('/') {
851 return Err(syn::Error::new_spanned(
852 lit,
853 "`path_prefix` must be an absolute path starting with `/`",
854 ));
855 }
856 if prefix.contains('?') || prefix.contains('#') {
857 return Err(syn::Error::new_spanned(
858 lit,
859 "`path_prefix` must not include a query string or fragment",
860 ));
861 }
862 if prefix
863 .chars()
864 .any(|ch| matches!(ch, '{' | '}' | '*' | '[' | ']' | '(' | ')' | ':'))
865 {
866 return Err(syn::Error::new_spanned(
867 lit,
868 "`path_prefix` is prefix-only and must not contain path parameters, wildcards, or regex syntax",
869 ));
870 }
871
872 let canonical = if prefix == "/" {
873 prefix
874 } else {
875 prefix.trim_end_matches('/').to_owned()
876 };
877 if canonical.is_empty() {
878 Ok("/".to_owned())
879 } else if canonical != "/" && canonical.split('/').skip(1).any(str::is_empty) {
880 Err(syn::Error::new_spanned(
881 lit,
882 "`path_prefix` must not contain empty path segments",
883 ))
884 } else {
885 Ok(canonical)
886 }
887}
888
889fn group_path_string(group: Option<&syn::Path>) -> String {
890 match group {
891 None => "main".to_string(),
892 Some(path) => path
893 .segments
894 .iter()
895 .map(|seg| seg.ident.to_string())
896 .collect::<Vec<_>>()
897 .join("::"),
898 }
899}
900
901struct ExportMetadata<'a> {
902 kind: ExportKind,
903 is_network: bool,
904 group: Option<&'a syn::Path>,
905 fn_name: &'a syn::Ident,
906 ctx_mut: bool,
907 params: &'a [(syn::Ident, syn::Type)],
908 ret_inner: &'a syn::Type,
909}
910
911fn export_metadata(input: ExportMetadata<'_>) -> syn::Result<TokenStream> {
912 let ExportMetadata {
913 kind,
914 is_network,
915 group,
916 fn_name,
917 ctx_mut,
918 params,
919 ret_inner,
920 } = input;
921 match kind {
922 ExportKind::Ethereum => export_metadata_eth(is_network, group, fn_name, ctx_mut, params, ret_inner),
923 ExportKind::Http(http) => export_metadata_http(is_network, group, fn_name, params, ret_inner, &http),
924 }
925}
926
927fn export_metadata_eth(
928 is_network: bool, group: Option<&syn::Path>, fn_name: &syn::Ident, ctx_mut: bool,
929 params: &[(syn::Ident, syn::Type)], ret_inner: &syn::Type,
930) -> syn::Result<TokenStream> {
931 let group_string = group_path_string(group);
932 let method_string = fn_name.to_string();
933 let param_types = params
934 .iter()
935 .map(|(_, ty)| quote::quote! { <#ty as lyquid::runtime::ethabi::EthAbiType>::DESC })
936 .collect::<Vec<_>>();
937 let param_count = param_types.len();
938 let section_name = syn::LitStr::new("lyquor.method.export.eth", Span::call_site());
939 let category = if is_network {
940 quote::quote! { lyquid::consts::CATEGORY_NETWORK }
941 } else {
942 quote::quote! { lyquid::consts::CATEGORY_INSTANCE }
943 };
944 let mutable = if ctx_mut {
945 quote::quote! { true }
946 } else {
947 quote::quote! { false }
948 };
949
950 Ok(quote::quote! {
951 #[doc(hidden)]
952 const _: () = {
953 const GROUP: &str = #group_string;
954 const METHOD: &str = #method_string;
955 const PARAM_COUNT: usize = #param_count;
956 const PARAM_TYPES: [lyquid::runtime::ethabi::EthAbiTypeDesc; PARAM_COUNT] = [#(#param_types,)*];
957 const RETURN_TYPES: &'static [lyquid::runtime::ethabi::EthAbiTypeDesc] =
958 <#ret_inner as lyquid::runtime::ethabi::EthAbiReturn>::TYPES;
959
960 const LEN: usize = lyquid::consts::export_len(
961 GROUP,
962 METHOD,
963 &PARAM_TYPES,
964 RETURN_TYPES,
965 );
966
967 #[unsafe(link_section = #section_name)]
968 #[used]
969 static EXPORT: [u8; LEN] = lyquid::consts::export_encode::<LEN>(
970 #category,
971 #mutable,
972 GROUP,
973 METHOD,
974 &PARAM_TYPES,
975 RETURN_TYPES,
976 );
977 };
978 })
979}
980
981fn export_metadata_http(
982 is_network: bool, group: Option<&syn::Path>, fn_name: &syn::Ident, params: &[(syn::Ident, syn::Type)],
983 ret_inner: &syn::Type, http: &HttpExportAttr,
984) -> syn::Result<TokenStream> {
985 if is_network {
986 return Err(syn::Error::new_spanned(
987 fn_name,
988 "`export = http` is only supported on #[lyquid::method::instance]",
989 ));
990 }
991 if params.len() != 1 {
992 return Err(syn::Error::new_spanned(
993 fn_name,
994 "HTTP exports must take exactly one request parameter: `req: http::Request`",
995 ));
996 }
997 let (_, param_ty) = ¶ms[0];
998 if !is_http_type(param_ty, "Request") {
999 return Err(syn::Error::new_spanned(
1000 param_ty,
1001 "HTTP export request parameter must be `http::Request`",
1002 ));
1003 }
1004 if !is_http_type(ret_inner, "Response") {
1005 return Err(syn::Error::new_spanned(
1006 ret_inner,
1007 "HTTP export return type must be `LyquidResult<http::Response>`",
1008 ));
1009 }
1010 let group_string = group_path_string(group);
1011 let method_string = fn_name.to_string();
1012 let http_method = &http.method;
1013 let path_prefix = &http.path_prefix;
1014 validate_http_export_component_len("group", &group_string, fn_name)?;
1015 validate_http_export_component_len("method", &method_string, fn_name)?;
1016 validate_http_export_component_len("HTTP method", http_method, fn_name)?;
1017 validate_http_export_component_len("path_prefix", path_prefix, fn_name)?;
1018 let section_name = syn::LitStr::new("lyquor.method.export.http", Span::call_site());
1019
1020 Ok(quote::quote! {
1021 #[doc(hidden)]
1022 const _: () = {
1023 const GROUP: &str = #group_string;
1024 const METHOD: &str = #method_string;
1025 const HTTP_METHOD: &str = #http_method;
1026 const PATH_PREFIX: &str = #path_prefix;
1027 const LEN: usize = lyquid::consts::http_export_len(
1028 GROUP,
1029 METHOD,
1030 HTTP_METHOD,
1031 PATH_PREFIX,
1032 );
1033
1034 #[unsafe(link_section = #section_name)]
1035 #[used]
1036 static EXPORT: [u8; LEN] = lyquid::consts::http_export_encode::<LEN>(
1037 lyquid::consts::CATEGORY_INSTANCE,
1038 GROUP,
1039 METHOD,
1040 HTTP_METHOD,
1041 PATH_PREFIX,
1042 );
1043 };
1044 })
1045}
1046
1047fn validate_http_export_component_len<T: quote::ToTokens>(label: &str, value: &str, span: T) -> syn::Result<()> {
1048 if value.len() <= u16::MAX as usize {
1049 return Ok(());
1050 }
1051 Err(syn::Error::new_spanned(
1052 span,
1053 format!(
1054 "HTTP export {label} is too long to encode; maximum length is {} bytes",
1055 u16::MAX
1056 ),
1057 ))
1058}
1059
1060fn is_http_type(ty: &syn::Type, expected: &str) -> bool {
1061 let syn::Type::Path(path) = ty else {
1062 return false;
1063 };
1064 if path.qself.is_some() || path.path.segments.len() < 2 {
1065 return false;
1066 }
1067 let mut segments = path.path.segments.iter().rev();
1068 let Some(last) = segments.next() else {
1069 return false;
1070 };
1071 let Some(prev) = segments.next() else {
1072 return false;
1073 };
1074 last.ident == expected && prev.ident == "http" && matches!(&last.arguments, syn::PathArguments::None)
1075}
1076
1077fn parse_method_group(attr: TokenStream, attr_name: &str) -> syn::Result<Option<syn::Path>> {
1079 if attr.is_empty() {
1080 return Ok(None);
1081 }
1082
1083 let parser = |input: syn::parse::ParseStream| -> syn::Result<syn::Path> {
1084 let key: syn::Ident = input.parse()?;
1085 if key != "group" {
1086 return Err(syn::Error::new_spanned(
1087 key,
1088 format!("expected `group = foo::bar` for #[{attr_name}]"),
1089 ));
1090 }
1091 input.parse::<syn::Token![=]>()?;
1092 let path: syn::Path = input.parse()?;
1093
1094 if input.peek(syn::Token![,]) {
1095 input.parse::<syn::Token![,]>()?;
1096 if !input.is_empty() {
1097 return Err(input.error("unexpected extra arguments"));
1098 }
1099 } else if !input.is_empty() {
1100 return Err(input.error("unexpected extra arguments"));
1101 }
1102
1103 Ok(path)
1104 };
1105
1106 let parsed = syn::parse::Parser::parse2(&parser, attr)?;
1107 validate_group_path(&parsed)?;
1108 Ok(Some(parsed))
1109}
1110
1111fn validate_group_path(path: &syn::Path) -> syn::Result<()> {
1112 if path.leading_colon.is_some() {
1113 return Err(syn::Error::new_spanned(
1114 path,
1115 "group must be a relative path like `foo::bar`",
1116 ));
1117 }
1118 if path.segments.iter().any(|seg| !seg.arguments.is_empty()) {
1119 return Err(syn::Error::new_spanned(
1120 path,
1121 "group path must not contain generic arguments",
1122 ));
1123 }
1124 Ok(())
1125}
1126
1127fn oracle_two_phase_name(path: &syn::Path) -> Option<syn::Ident> {
1128 let mut iter = path.segments.iter();
1129 let oracle = iter.next()?;
1130 let two_phase = iter.next()?;
1131 let name = iter.next()?;
1132 if oracle.ident != "oracle" || two_phase.ident != "two_phase" {
1133 return None;
1134 }
1135 if iter.next().is_some() {
1136 return None;
1137 }
1138 Some(name.ident.clone())
1139}
1140
1141fn is_option_certified_call_params(ty: &syn::Type) -> bool {
1142 let syn::Type::Path(path) = ty else {
1143 return false;
1144 };
1145 let option_seg = match path.path.segments.last() {
1146 Some(seg) if seg.ident == "Option" => seg,
1147 _ => return false,
1148 };
1149 let syn::PathArguments::AngleBracketed(args) = &option_seg.arguments else {
1150 return false;
1151 };
1152 let mut iter = args.args.iter();
1153 let inner = match iter.next() {
1154 Some(syn::GenericArgument::Type(inner)) => inner,
1155 _ => return false,
1156 };
1157 if iter.next().is_some() {
1158 return false;
1159 }
1160 let syn::Type::Path(inner_path) = inner else {
1161 return false;
1162 };
1163 inner_path
1164 .path
1165 .segments
1166 .last()
1167 .map(|seg| seg.ident == "CertifiedCallParams")
1168 .unwrap_or(false)
1169}
1170
1171#[proc_macro_attribute]
1174pub fn prefix_item(attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream {
1175 use quote::ToTokens;
1176 match syn::parse_macro_input!(item as syn::Item) {
1177 syn::Item::Fn(mut func) => {
1178 func.sig.ident = add_prefix(attr.into(), func.sig.ident);
1179 func.to_token_stream()
1180 }
1181 syn::Item::Mod(mut mo) => {
1182 mo.ident = add_prefix(attr.into(), mo.ident);
1183 mo.to_token_stream()
1184 }
1185 _ => panic!("unsupported item"),
1186 }
1187 .into()
1188}
1189
1190#[proc_macro_attribute]
1193pub fn network_function(attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream {
1194 let func = syn::parse_macro_input!(item as syn::ItemFn);
1195 match expand_network_function(attr.into(), func) {
1196 Ok(tokens) => tokens.into(),
1197 Err(err) => err.to_compile_error().into(),
1198 }
1199}
1200
1201#[proc_macro_attribute]
1204pub fn instance_function(attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream {
1205 let func = syn::parse_macro_input!(item as syn::ItemFn);
1206 match expand_instance_function(attr.into(), func) {
1207 Ok(tokens) => tokens.into(),
1208 Err(err) => err.to_compile_error().into(),
1209 }
1210}
1211
1212#[proc_macro]
1215pub fn prefix_call(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
1216 struct Input {
1217 attr: TokenStream,
1218 _comma: syn::Token![,],
1219 expr: syn::Expr,
1220 }
1221
1222 impl syn::parse::Parse for Input {
1223 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
1224 let content;
1225 syn::parenthesized!(content in input);
1226 Ok(Self {
1227 attr: content.parse()?,
1228 _comma: input.parse()?,
1229 expr: input.parse()?,
1230 })
1231 }
1232 }
1233
1234 let Input { attr, expr, .. } = syn::parse_macro_input!(input as Input);
1235
1236 match expr {
1237 syn::Expr::Call(mut call) => {
1238 if let syn::Expr::Path(ref mut func_path) = *call.func {
1239 if let Some(ident) = func_path.path.get_ident() {
1240 func_path.path.segments[0].ident = add_prefix(attr, ident.clone());
1241 }
1242 quote::quote!(#call).into()
1243 } else {
1244 panic!("expected a simple function call");
1245 }
1246 }
1247 syn::Expr::Path(path_expr) => {
1248 if let Some(ident) = path_expr.path.get_ident() {
1249 let new_ident = add_prefix(attr, ident.clone());
1250 quote::quote!(#new_ident).into()
1251 } else {
1252 quote::quote!(#path_expr).into()
1253 }
1254 }
1255 _ => panic!("expected a function call or an identifier"),
1256 }
1257}
1258
1259#[proc_macro]
1261pub fn setup_lyquid_state_variables(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
1262 let mut toplevel_tokens = TokenStream::from(item).into_iter(); let struct_suffix = next_token_is_ident(&mut toplevel_tokens).expect("expect struct suffix name");
1264 let init_func = next_token_is_ident(&mut toplevel_tokens).expect("expect init func name");
1265 let categories = next_token_is_group(&mut toplevel_tokens).expect("expect a list of categories");
1266 let mut cats = HashMap::new();
1267 for token in categories.stream().into_iter() {
1268 let grp = token_is_group(token).expect("expect category info");
1269 let mut iter = grp.stream().into_iter();
1270 let cat_id = next_token_is_ident(&mut iter).expect("expect category identifer");
1271 let cat_prefix = next_token_is_ident(&mut iter).expect("expect category prefix");
1272 cats.insert(cat_id.to_string(), (TokenStream::from_iter(iter), cat_prefix));
1273 }
1274 let mut struct_fields = HashMap::new(); let mut struct_inits = HashMap::new(); let mut var_setup = TokenStream::new();
1277
1278 for def in toplevel_tokens {
1280 let mut def_iter = token_is_group(def)
1281 .expect("expect state variable definition")
1282 .stream()
1283 .into_iter();
1284 let cat = next_token_is_ident(&mut def_iter).expect("expect storage category");
1285 let mut cat_str = cat.to_string();
1286 let name = next_token_is_ident(&mut def_iter).expect("expect variable identifer");
1287 let name_str = name.to_string();
1288 let type_;
1289 let init;
1290
1291 match cat_str.as_str() {
1292 "oracle" => {
1293 cat_str = "network".to_string();
1294 type_ = quote::quote! { runtime::oracle::StateVar<'static> };
1295 init = quote::quote! { runtime::oracle::StateVar::new(stringify!(#name)) };
1296 }
1297 _ => {
1298 type_ = def_iter.next().expect("expect variable type").into();
1299 init = next_token_is_group(&mut def_iter)
1300 .expect("expect an initializer")
1301 .stream();
1302 }
1303 }
1304
1305 let mut type_ = type_;
1306 let mut init = init;
1307
1308 let (cat_value, _) = match cats.get(&cat_str) {
1309 Some(v) => v,
1310 None => panic!("invalid category {}", cat),
1311 };
1312
1313 let field_ts = struct_fields.entry(cat_str.clone()).or_insert_with(TokenStream::new);
1314 let init_ts = struct_inits.entry(cat_str.clone()).or_insert_with(TokenStream::new);
1315
1316 let cat_num: u8 = match cat_str.as_str() {
1318 "instance" => 0x1,
1319 "network" => 0x2,
1320 _ => panic!("Unknown category for the allocator."),
1321 };
1322 init = quote::quote! {{
1323 runtime::set_allocator_category(#cat_num);
1324 #init
1325 }};
1326
1327 if cat_str == "instance" {
1328 init = quote::quote! {runtime::sync::RwLock::new(#init)};
1329 type_ = quote::quote! {runtime::sync::RwLock<#type_>};
1330 }
1331
1332 var_setup.extend([quote::quote! {
1333 let ptr: *mut (#type_) = Box::leak(Box::new(#init));
1336 let bytes = (ptr as u64).to_be_bytes();
1337 pa.set(#cat_value, #name_str.as_bytes(), &bytes).expect(FAIL_WRITE_STATE);
1338 }]);
1339
1340 field_ts.extend([quote::quote! {
1341 pub #name: &'static mut (#type_),
1342 }]);
1343
1344 init_ts.extend([quote::quote! {
1345 #name: {
1346 let bytes = pa.get(#cat_value, &#name_str.as_bytes())?.ok_or(LyquidError::Init)?;
1348 let addr = u64::from_be_bytes(bytes.try_into().map_err(|_| LyquidError::Init)?);
1349 unsafe { &mut *(addr as *mut (#type_)) }
1350 },
1351 }]);
1352 }
1353
1354 var_setup.extend(
1355 [quote::quote! {
1356 runtime::set_allocator_category(0x2);
1359 let ptr: *mut runtime::internal::BuiltinNetworkState = Box::leak(Box::new(runtime::internal::BuiltinNetworkState::new()));
1360 let bytes = (ptr as u64).to_be_bytes();
1361 internal_pa.set(StateCategory::Network, "network".as_bytes(), &bytes).expect(FAIL_WRITE_STATE);
1362 }]
1363 );
1364 var_setup.extend(
1365 [quote::quote! {
1366 runtime::set_allocator_category(0x1);
1369 let ptr: *mut runtime::internal::BuiltinInstanceState = Box::leak(Box::new(runtime::internal::BuiltinInstanceState::new()));
1370 let bytes = (ptr as u64).to_be_bytes();
1371 internal_pa.set(StateCategory::Instance, "instance".as_bytes(), &bytes).expect(FAIL_WRITE_STATE);
1372 }]
1373 );
1374
1375 struct_fields
1376 .entry("network".to_string())
1377 .or_insert_with(TokenStream::new)
1378 .extend([quote::quote! {
1379 pub __internal: &'static mut runtime::internal::BuiltinNetworkState,
1380 }]);
1381 struct_inits
1382 .entry("network".to_string())
1383 .or_insert_with(TokenStream::new)
1384 .extend([quote::quote! {
1385 __internal: {
1386 let bytes = internal_pa.get(StateCategory::Network, "network".as_bytes())?.ok_or(LyquidError::Init)?;
1388 let addr = u64::from_be_bytes(bytes.try_into().map_err(|_| LyquidError::Init)?);
1389 unsafe { &mut *(addr as *mut runtime::internal::BuiltinNetworkState) }
1390 },
1391 }]);
1392
1393 struct_fields
1394 .entry("instance".to_string())
1395 .or_insert_with(TokenStream::new)
1396 .extend([quote::quote! {
1397 pub __internal: &'static mut runtime::internal::BuiltinInstanceState,
1398 }]);
1399 struct_inits
1400 .entry("instance".to_string())
1401 .or_insert_with(TokenStream::new)
1402 .extend([quote::quote! {
1403 __internal: {
1404 let bytes = internal_pa.get(StateCategory::Instance, "instance".as_bytes())?.ok_or(LyquidError::Init)?;
1406 let addr = u64::from_be_bytes(bytes.try_into().map_err(|_| LyquidError::Init)?);
1407 unsafe { &mut *(addr as *mut runtime::internal::BuiltinInstanceState) }
1408 },
1409 }]);
1410
1411 let mut structs = TokenStream::new();
1413 for (cat, (_, cat_prefix)) in cats.iter() {
1414 let field_ts = struct_fields.entry(cat.clone()).or_insert_with(TokenStream::new);
1415 let init_ts = struct_inits.entry(cat.clone()).or_insert_with(TokenStream::new);
1416 let sname = quote::format_ident!("{}{}", cat_prefix, struct_suffix);
1417 structs.extend([quote::quote! {
1418 pub struct #sname {
1420 #field_ts
1421 }
1422
1423 impl runtime::internal::StateAccessor for #sname {
1424 fn new() -> Result<Self, LyquidError> {
1425 let internal_pa = runtime::internal::PrefixedAccess::new(Vec::from(lyquid::INTERNAL_STATE_PREFIX));
1426 let pa = runtime::internal::PrefixedAccess::new(Vec::from(lyquid::VAR_CATALOG_PREFIX));
1427 Ok(Self {
1428 #init_ts
1429 })
1430 }
1431 }
1432 }]);
1433 }
1434 quote::quote! {
1435 #structs
1436
1437 #[unsafe(no_mangle)]
1440 unsafe fn #init_func(category: u32) {
1441 const FAIL_WRITE_STATE: &str = "cannot write to low-level state store during LiteMemory initialization";
1442 let internal_pa = runtime::internal::PrefixedAccess::new(Vec::from(lyquid::INTERNAL_STATE_PREFIX));
1443 let pa = runtime::internal::PrefixedAccess::new(Vec::from(lyquid::VAR_CATALOG_PREFIX));
1444 #var_setup
1445 runtime::set_allocator_category(category as u8);
1446 }
1447 }
1448 .into()
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453 use super::*;
1454
1455 #[test]
1456 fn http_export_attr_accepts_supported_method_and_canonical_prefix() {
1457 let attr = parse_method_attr(
1458 quote::quote!(export = http, method = "POST", path_prefix = "/api/"),
1459 "lyquid::method::instance",
1460 )
1461 .expect("HTTP export attribute should parse");
1462
1463 let Some(ExportKind::Http(http)) = attr.export else {
1464 panic!("HTTP metadata should be present");
1465 };
1466 assert_eq!(http.method, "POST");
1467 assert_eq!(http.path_prefix, "/api");
1468 }
1469
1470 #[test]
1471 fn http_export_attr_rejects_unsupported_method() {
1472 let err = match parse_method_attr(
1473 quote::quote!(export = http, method = "CONNECT", path_prefix = "/api"),
1474 "lyquid::method::instance",
1475 ) {
1476 Ok(_) => panic!("unsupported HTTP method should fail"),
1477 Err(err) => err,
1478 };
1479 assert!(err.to_string().contains("unsupported HTTP export method"));
1480 }
1481
1482 #[test]
1483 fn http_export_attr_rejects_non_absolute_prefix() {
1484 let err = match parse_method_attr(
1485 quote::quote!(export = http, method = "GET", path_prefix = "api"),
1486 "lyquid::method::instance",
1487 ) {
1488 Ok(_) => panic!("non-absolute path prefix should fail"),
1489 Err(err) => err,
1490 };
1491 assert!(err.to_string().contains("absolute path"));
1492 }
1493
1494 #[test]
1495 fn http_export_signature_validation_accepts_request_response_pair() {
1496 export_metadata_http(
1497 false,
1498 None,
1499 "e::format_ident!("api"),
1500 &[(quote::format_ident!("req"), syn::parse_quote!(http::Request))],
1501 &syn::parse_quote!(http::Response),
1502 &HttpExportAttr {
1503 method: "GET".to_owned(),
1504 path_prefix: "/api".to_owned(),
1505 },
1506 )
1507 .expect("valid HTTP export signature should emit metadata");
1508 }
1509
1510 #[test]
1511 fn http_export_signature_validation_rejects_network_methods() {
1512 let err = match export_metadata_http(
1513 true,
1514 None,
1515 "e::format_ident!("api"),
1516 &[(quote::format_ident!("req"), syn::parse_quote!(http::Request))],
1517 &syn::parse_quote!(http::Response),
1518 &HttpExportAttr {
1519 method: "GET".to_owned(),
1520 path_prefix: "/api".to_owned(),
1521 },
1522 ) {
1523 Ok(_) => panic!("network HTTP export should fail"),
1524 Err(err) => err,
1525 };
1526 assert!(err.to_string().contains("only supported on"));
1527 }
1528
1529 #[test]
1530 fn http_export_signature_validation_rejects_oversized_metadata() {
1531 let path_prefix = format!("/{}", "a".repeat(u16::MAX as usize));
1532 let err = match export_metadata_http(
1533 false,
1534 None,
1535 "e::format_ident!("api"),
1536 &[(quote::format_ident!("req"), syn::parse_quote!(http::Request))],
1537 &syn::parse_quote!(http::Response),
1538 &HttpExportAttr {
1539 method: "GET".to_owned(),
1540 path_prefix,
1541 },
1542 ) {
1543 Ok(_) => panic!("oversized HTTP metadata should fail"),
1544 Err(err) => err,
1545 };
1546 assert!(err.to_string().contains("path_prefix"));
1547 }
1548}