Skip to main content

rullst_macros/
lib.rs

1#![cfg_attr(mutants, mutants::skip)]
2extern crate proc_macro;
3
4use proc_macro::TokenStream;
5use syn::parse_macro_input;
6
7#[cfg(test)]
8mod billable_tests;
9mod html_parser;
10mod live_parser;
11#[cfg(test)]
12mod memoize_tests;
13#[cfg(test)]
14mod require_role_tests;
15mod server_function;
16
17/// A macro for writing HTML inline in Rust.
18/// It compiles down to highly optimized string concatenations at compile time,
19/// and automatically escapes dynamic variables to prevent XSS.
20///
21/// Dynamic URL attributes (`href`, `src`, `action`, …) render `#` for script or
22/// non-media `data:` URLs; dynamic `style`/`on*` values are trusted-only.
23///
24/// # Example
25///
26/// This block is ignored only in the standalone proc-macro crate because the
27/// expansion deliberately calls the `rullst::html` runtime and adding that
28/// facade here would create a circular development dependency. The same dynamic
29/// expansion is compiled and asserted in `rullst/tests/html_snapshot_tests.rs`.
30///
31/// ```rust,ignore
32/// use rullst_macros::html;
33///
34/// let name = "Mundo";
35/// let page = html! {
36///     <div class="container">
37///         <h1>"Olá, " {name} "!"</h1>
38///     </div>
39/// };
40/// assert!(page.contains("Olá, Mundo!"));
41/// ```
42#[proc_macro]
43pub fn html(input: TokenStream) -> TokenStream {
44    let document = parse_macro_input!(input as html_parser::HtmlDocument);
45    let expanded = document.to_tokens();
46    expanded.into()
47}
48
49/// Proc macro attribute to define a Wasm Island client component.
50///
51/// It compiles dual versions depending on compilation targets:
52/// - On native server compiles, it wraps the component's HTML output in a `<div data-island="..." data-props="...">`
53/// - On wasm32-unknown-unknown compiles, it generates structural props parsing and registers a hydration function
54///
55/// Props that cannot be serialized to JSON leave `data-props` empty, so the
56/// server HTML renders without hydration instead of panicking.
57#[proc_macro_attribute]
58#[allow(clippy::collapsible_if)]
59pub fn island(_attr: TokenStream, item: TokenStream) -> TokenStream {
60    let input_fn = parse_macro_input!(item as syn::ItemFn);
61    let vis = &input_fn.vis;
62    let sig = &input_fn.sig;
63    let name = &sig.ident;
64    let body = &input_fn.block;
65
66    // Extract argument names and types
67    let mut arg_names = Vec::new();
68    let mut arg_types = Vec::new();
69
70    for arg in &sig.inputs {
71        if let syn::FnArg::Typed(pat_type) = arg {
72            if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
73                arg_names.push(&pat_ident.ident);
74                arg_types.push(&pat_type.ty);
75            }
76        }
77    }
78
79    let props_struct_name =
80        syn::Ident::new(&format!("{}_Props", name), proc_macro2::Span::call_site());
81
82    let hydrate_fn_name =
83        syn::Ident::new(&format!("hydrate_{}", name), proc_macro2::Span::call_site());
84
85    let expanded = quote::quote! {
86        #[cfg(not(target_arch = "wasm32"))]
87        #vis fn #name(#(#arg_names: #arg_types),*) -> String {
88            let inner_html = {
89                #body
90            };
91
92            // Props that cannot be represented as JSON (for example a u128
93            // above u64::MAX) leave `data-props` empty, so the island renders
94            // without hydration instead of panicking.
95            let props_json = (|| -> ::core::result::Result<String, serde_json::Error> {
96                let mut props = serde_json::Map::new();
97                #(
98                    props.insert(
99                        stringify!(#arg_names).to_string(),
100                        serde_json::to_value(&#arg_names)?,
101                    );
102                )*
103                ::core::result::Result::Ok(serde_json::Value::Object(props).to_string())
104            })()
105            .unwrap_or_default();
106
107            let escaped_props = rullst::html::escape_str(&props_json);
108
109            format!(
110                "<div data-island=\"{}\" data-props=\"{}\">{}</div>",
111                stringify!(#name),
112                escaped_props,
113                inner_html
114            )
115        }
116
117        #[cfg(target_arch = "wasm32")]
118        #vis fn #name(#(#arg_names: #arg_types),*) -> String {
119            let Some(element) = web_sys::window()
120                .and_then(|w| w.document())
121                .and_then(|d| d.create_element("div").ok())
122            else {
123                return String::new();
124            };
125            let _ = {
126                #body
127            };
128            String::new()
129        }
130
131        #[cfg(target_arch = "wasm32")]
132        #[derive(serde::Deserialize)]
133        #[allow(non_camel_case_types)]
134        struct #props_struct_name {
135            #(#arg_names: #arg_types),*
136        }
137
138        #[cfg(target_arch = "wasm32")]
139        #[wasm_bindgen::prelude::wasm_bindgen]
140        #[allow(non_snake_case)]
141        pub fn #hydrate_fn_name(element: web_sys::Element, props_json: &str) {
142            let props: #props_struct_name = match serde_json::from_str(props_json) {
143                Ok(p) => p,
144                Err(_) => return,
145            };
146
147            #(let #arg_names = props.#arg_names;)*
148            let element = element;
149
150            let _ = {
151                #body
152            };
153        }
154    };
155
156    expanded.into()
157}
158
159/// Proc macro attribute to define a Live Component.
160/// Automatically implements the `LiveComponent` trait and wires `#[live_event]` methods.
161///
162/// Only the first present `rullst_event`, `action` or `event` string field
163/// selects a handler, and at most one runs per message; other payload keys
164/// and values, such as form inputs, never dispatch.
165#[proc_macro_attribute]
166pub fn live_component(_attr: TokenStream, item: TokenStream) -> TokenStream {
167    let input = item.into();
168    live_parser::parse_live_component(input).into()
169}
170
171/// Marker attribute for events handled by a Live Component.
172#[proc_macro_attribute]
173pub fn live_event(_attr: TokenStream, item: TokenStream) -> TokenStream {
174    item
175}
176
177/// A macro for intelligent caching of component renders or database queries.
178/// It wraps a function, caching the returned output based on the function's arguments.
179/// If the function is called again with the same arguments, it returns the cached HTML immediately.
180///
181/// Cache keys identify the annotated function by `module_path!()`, its name and
182/// the attribute's `file!()`/`line!()`/`column!()`, so same-named functions in
183/// different modules, crates or `impl` blocks never share cached results.
184///
185/// Results live in the bounded process-wide `rullst::cache::memory` store
186/// (4,096 entries, 32 MiB, one hour each; oldest evicted first). A call whose
187/// key plus serialized result exceeds 256 KiB, or whose arguments cannot be
188/// serialized to JSON (e.g. a `u128` above `u64::MAX`), runs uncached.
189#[proc_macro_attribute]
190pub fn memoize(_attr: TokenStream, item: TokenStream) -> TokenStream {
191    let input_fn = parse_macro_input!(item as syn::ItemFn);
192    expand_memoize(&input_fn).into()
193}
194
195fn expand_memoize(input_fn: &syn::ItemFn) -> proc_macro2::TokenStream {
196    let vis = &input_fn.vis;
197    let sig = &input_fn.sig;
198    let name = &sig.ident;
199    let body = &input_fn.block;
200    let output_type = match &sig.output {
201        syn::ReturnType::Default => quote::quote!(()),
202        syn::ReturnType::Type(_, ty) => quote::quote!(#ty),
203    };
204
205    // Extract argument names
206    let mut arg_names = Vec::new();
207    let mut arg_types = Vec::new();
208    for arg in &sig.inputs {
209        if let syn::FnArg::Typed(pat_type) = arg
210            && let syn::Pat::Ident(pat_ident) = &*pat_type.pat
211        {
212            arg_names.push(&pat_ident.ident);
213            arg_types.push(&pat_type.ty);
214        }
215    }
216
217    quote::quote! {
218        #vis fn #name(#(#arg_names: #arg_types),*) -> #output_type {
219            // The key combines a per-function identity with the serialized
220            // arguments. `module_path!()` separates modules and crates; the
221            // attribute's location separates same-named associated functions.
222            // Arguments that cannot be represented as JSON (for example a u128
223            // above u64::MAX) run the body uncached instead of panicking.
224            let cache_key = (|| -> ::core::result::Result<serde_json::Value, serde_json::Error> {
225                ::core::result::Result::Ok(serde_json::Value::Array(vec![
226                    #(serde_json::to_value(&#arg_names)?),*
227                ]))
228            })()
229            .ok()
230            .map(|arguments| {
231                format!(
232                    "{}:{}",
233                    concat!(
234                        module_path!(),
235                        "::",
236                        stringify!(#name),
237                        "@",
238                        file!(),
239                        ":",
240                        line!(),
241                        ":",
242                        column!()
243                    ),
244                    arguments
245                )
246            });
247
248            // Check if it exists in the global Rullst memory cache
249            if let Some(cache_key) = &cache_key {
250                if let Some(cached) = rullst::cache::memory::get(cache_key) {
251                    // If it's a String (HTML output), we can downcast or deserialize it.
252                    // For simplicity, we assume String return types.
253                    if let Ok(cached_str) = serde_json::from_str::<#output_type>(&cached) {
254                        return cached_str;
255                    }
256                }
257            }
258
259            // Otherwise, execute the function
260            let result: #output_type = { #body };
261
262            // Store it in the cache
263            if let Some(cache_key) = &cache_key {
264                if let Ok(serialized) = serde_json::to_string(&result) {
265                    rullst::cache::memory::set(cache_key, &serialized);
266                }
267            }
268
269            result
270        }
271    }
272}
273
274/// Legacy compatibility marker that preserves the annotated function unchanged.
275///
276/// Route registration is defined by the `routes!` macro in `rullst-core`. This attribute never
277/// implemented path registration or content negotiation and is retained for one compatibility
278/// cycle so existing code does not have its function signature rewritten unexpectedly.
279#[deprecated(
280    since = "12.0.0",
281    note = "use rullst::routes! for route registration; this legacy marker will be removed"
282)]
283#[proc_macro_attribute]
284pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
285    if attr.is_empty() {
286        item
287    } else {
288        syn::Error::new(
289            proc_macro2::Span::call_site(),
290            "#[route] does not register paths; use rullst::routes! instead",
291        )
292        .into_compile_error()
293        .into()
294    }
295}
296
297/// Defines a bounded, dual-target Rullst server function.
298///
299/// The annotated item must be a concrete async free function with at most 16
300/// identifier parameters and a `rullst::rpc::RpcResult<T>` return type. Native
301/// builds retain its body and generate `<name>_rpc_router()`. Wasm builds
302/// serialize the arguments into the versioned `rullst.client` envelope and call
303/// the exact generated endpoint.
304///
305/// `path = "/api/rpc/..."` is optional; otherwise the final path segment is the
306/// function name. Mount the generated router before applying Rullst's
307/// production security and application identity/authorization layers.
308#[proc_macro_attribute]
309pub fn server_function(attr: TokenStream, item: TokenStream) -> TokenStream {
310    match server_function::expand(attr.into(), item.into()) {
311        Ok(expanded) => expanded.into(),
312        Err(error) => error.into_compile_error().into(),
313    }
314}
315
316#[proc_macro_attribute]
317pub fn require_role(attr: TokenStream, item: TokenStream) -> TokenStream {
318    let role = parse_macro_input!(attr as syn::LitStr);
319    let input_fn = parse_macro_input!(item as syn::ItemFn);
320    match expand_require_role(&role, &input_fn) {
321        Ok(expanded) => expanded.into(),
322        Err(error) => error.to_compile_error().into(),
323    }
324}
325
326fn expand_require_role(
327    role: &syn::LitStr,
328    input_fn: &syn::ItemFn,
329) -> Result<proc_macro2::TokenStream, syn::Error> {
330    if input_fn.sig.asyncness.is_none() {
331        return Err(syn::Error::new_spanned(
332            input_fn.sig.fn_token,
333            "#[require_role] supports async handlers only",
334        ));
335    }
336    let role_value = role.value();
337    if role_value.trim().is_empty()
338        || role_value.chars().count() > 128
339        || role_value.chars().any(char::is_control)
340    {
341        return Err(syn::Error::new_spanned(
342            role,
343            "#[require_role] needs a non-empty role of at most 128 characters without controls",
344        ));
345    }
346    let has_user = input_fn.sig.inputs.iter().any(|argument| match argument {
347        syn::FnArg::Receiver(_) => false,
348        syn::FnArg::Typed(argument) => pattern_binds_user(&argument.pat),
349    });
350    if !has_user {
351        return Err(syn::Error::new_spanned(
352            &input_fn.sig.inputs,
353            "#[require_role] requires a handler parameter that binds an authenticated `user`",
354        ));
355    }
356
357    let attributes = &input_fn.attrs;
358    let vis = &input_fn.vis;
359    let mut signature = input_fn.sig.clone();
360    signature.output = syn::parse_quote!(-> rullst::response::Response);
361    let body = &input_fn.block;
362
363    let expanded = quote::quote! {
364        #(#attributes)*
365        #vis #signature {
366            if !rullst::auth::HasRole::has_role(&user, #role) {
367                return rullst::response::IntoResponse::into_response((
368                    rullst::http::StatusCode::FORBIDDEN,
369                    "Forbidden: Insufficient privileges",
370                ));
371            }
372
373            let result = async move { #body }.await;
374            rullst::response::IntoResponse::into_response(result)
375        }
376    };
377    Ok(expanded)
378}
379
380fn pattern_binds_user(pattern: &syn::Pat) -> bool {
381    match pattern {
382        syn::Pat::Ident(binding) => {
383            binding.ident == "user"
384                || binding
385                    .subpat
386                    .as_ref()
387                    .is_some_and(|(_, pattern)| pattern_binds_user(pattern))
388        }
389        syn::Pat::Or(pattern) => pattern.cases.iter().any(pattern_binds_user),
390        syn::Pat::Paren(pattern) => pattern_binds_user(&pattern.pat),
391        syn::Pat::Reference(pattern) => pattern_binds_user(&pattern.pat),
392        syn::Pat::Slice(pattern) => pattern.elems.iter().any(pattern_binds_user),
393        syn::Pat::Struct(pattern) => pattern
394            .fields
395            .iter()
396            .any(|field| pattern_binds_user(&field.pat)),
397        syn::Pat::Tuple(pattern) => pattern.elems.iter().any(pattern_binds_user),
398        syn::Pat::TupleStruct(pattern) => pattern.elems.iter().any(pattern_binds_user),
399        syn::Pat::Type(pattern) => pattern_binds_user(&pattern.pat),
400        _ => false,
401    }
402}
403
404#[proc_macro_derive(Billable)]
405pub fn derive_billable(input: TokenStream) -> TokenStream {
406    let input = parse_macro_input!(input as syn::DeriveInput);
407    match expand_billable(&input) {
408        Ok(expanded) => expanded.into(),
409        Err(error) => error.into_compile_error().into(),
410    }
411}
412
413fn expand_billable(input: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
414    let name = &input.ident;
415    let syn::Data::Struct(data_struct) = &input.data else {
416        return Err(syn::Error::new_spanned(
417            input,
418            "Billable can only be derived for a struct with named fields",
419        ));
420    };
421    let syn::Fields::Named(fields) = &data_struct.fields else {
422        return Err(syn::Error::new_spanned(
423            &data_struct.fields,
424            "Billable requires a struct with a named `email: String` field",
425        ));
426    };
427    let has_field = |expected: &str| {
428        fields
429            .named
430            .iter()
431            .any(|field| field.ident.as_ref().is_some_and(|ident| ident == expected))
432    };
433    if !has_field("email") {
434        return Err(syn::Error::new_spanned(
435            fields,
436            "Billable requires a named `email: String` field",
437        ));
438    }
439
440    let sub_id_fn = if has_field("subscription_id") {
441        quote::quote! {
442            fn subscription_id(&self) -> Option<String> {
443                self.subscription_id.clone()
444            }
445        }
446    } else {
447        quote::quote! {}
448    };
449
450    let tier_fn = if has_field("tier") {
451        quote::quote! {
452            fn tier(&self) -> Option<String> {
453                self.tier.clone()
454            }
455        }
456    } else {
457        quote::quote! {}
458    };
459
460    let has_grace_start = has_field("grace_period_starts_at");
461    let has_grace_end = has_field("grace_period_ends_at");
462    if has_grace_start != has_grace_end {
463        return Err(syn::Error::new_spanned(
464            fields,
465            "Billable grace periods require both `grace_period_starts_at: Option<i64>` and `grace_period_ends_at: Option<i64>`",
466        ));
467    }
468    let grace_period_fn = if has_grace_start {
469        quote::quote! {
470            fn grace_period(
471                &self,
472            ) -> Result<Option<rullst::capital::GracePeriod>, rullst::capital::CapitalError> {
473                match (self.grace_period_starts_at, self.grace_period_ends_at) {
474                    (Some(starts_at), Some(ends_at)) => {
475                        rullst::capital::GracePeriod::new(starts_at, ends_at).map(Some)
476                    }
477                    (None, None) => Ok(None),
478                    _ => Err(rullst::capital::CapitalError::SubscriptionError(
479                        "grace period requires both start and end timestamps".to_string(),
480                    )),
481                }
482            }
483        }
484    } else {
485        quote::quote! {}
486    };
487
488    let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
489    Ok(quote::quote! {
490        impl #impl_generics rullst::capital::Billable for #name #type_generics #where_clause {
491            fn email(&self) -> String {
492                self.email.clone()
493            }
494
495            #sub_id_fn
496            #tier_fn
497            #grace_period_fn
498        }
499    })
500}