Skip to main content

lyquid_proc/
lib.rs

1#![allow(dead_code)]
2
3//! Compile-time expansion for Lyquid state, method, and call syntax.
4//!
5//! `lyquid-proc` translates the source-level Lyquid DSL into the guest ABI exported by `lyquid`.
6//! It validates method signatures, prefixes network, instance, Ethereum, oracle, and UPC entry
7//! point names, encodes method metadata into custom sections, builds context wrappers, and emits
8//! state-variable initialization glue. Runtime crates consume the generated names and metadata
9//! after `lyquor-wasm` extracts them from the compiled module.
10
11use 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                // Handle grouped tokens like ($($group)::*) - stringify the contents
61                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
349// Expands #[lyquid::method::network], with a constructor special-case.
350fn 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    // Parse optional group metadata and lower to __lyquid_categorize_methods.
370    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: &params,
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
432// Expands #[lyquid::method::instance], optionally handling upc(...) or oracle two-phase helpers.
433fn 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            // Special-case oracle two-phase aggregate to a fixed ABI entrypoint.
453            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: &params,
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
533// Lower constructor into the same wrapper shape used by generated network methods.
534fn 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: &params,
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
600// Lower upc(...) instance functions, rewriting signatures for response handlers.
601fn 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) = &params[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
685// Parse #[lyquid::method::instance] arguments: group = foo::bar, or upc(...).
686fn 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) = &params[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
1077// Parses group metadata and validates it as a relative path (foo::bar).
1078fn 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// Internal helper: prefixes a function or module name.
1172/// Rewrites an item with a generated export prefix for Lyquid runtime entry points.
1173#[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// #[lyquid::method::network]
1191/// Marks a function as a network Lyquid method and emits the runtime entry point metadata.
1192#[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// #[lyquid::method::instance]
1202/// Marks a function as an instance Lyquid method and emits the runtime entry point metadata.
1203#[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// Internal helper: prefix a call site to match prefixed items.
1213/// Expands a prefixed runtime call expression used by generated Lyquid wrappers.
1214#[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/// Generates Lyquid state accessors and the initialization entry point for state variables.
1260#[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(); // use proc_macro2 instead of proc_macro as it is more convenient
1263    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(); // maps from categories to a token stream of struct fields
1275    let mut struct_inits = HashMap::new(); // maps from categories to a token stream of field initializers
1276    let mut var_setup = TokenStream::new();
1277
1278    //let mut extra = TokenStream::new();
1279    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        // Switch to the correct allocator.
1317        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            // the pointer (only need to do it once, upon initialization of the instance's
1334            // LiteMemory)
1335            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                // retrieve the pointer for Box<T>
1347                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            // the pointer (only need to do it once, upon initialization of the instance's
1357            // LiteMemory)
1358            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            // the pointer (only need to do it once, upon initialization of the instance's
1367            // LiteMemory)
1368            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                // retrieve the pointer for Box<T>
1387                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                // retrieve the pointer for Box<T>
1405                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    // now we summary up each category and generate output
1412    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        /// Macro-generated Lyquid state accessor for one state category.
1419        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        //#extra
1438
1439        #[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            &quote::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            &quote::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            &quote::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}