#![cfg_attr(mutants, mutants::skip)]
extern crate proc_macro;
use proc_macro::TokenStream;
use syn::parse_macro_input;
#[cfg(test)]
mod billable_tests;
mod html_parser;
mod live_parser;
#[cfg(test)]
mod memoize_tests;
#[cfg(test)]
mod require_role_tests;
mod server_function;
#[proc_macro]
pub fn html(input: TokenStream) -> TokenStream {
let document = parse_macro_input!(input as html_parser::HtmlDocument);
let expanded = document.to_tokens();
expanded.into()
}
#[proc_macro_attribute]
#[allow(clippy::collapsible_if)]
pub fn island(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input_fn = parse_macro_input!(item as syn::ItemFn);
let vis = &input_fn.vis;
let sig = &input_fn.sig;
let name = &sig.ident;
let body = &input_fn.block;
let mut arg_names = Vec::new();
let mut arg_types = Vec::new();
for arg in &sig.inputs {
if let syn::FnArg::Typed(pat_type) = arg {
if let syn::Pat::Ident(pat_ident) = &*pat_type.pat {
arg_names.push(&pat_ident.ident);
arg_types.push(&pat_type.ty);
}
}
}
let props_struct_name =
syn::Ident::new(&format!("{}_Props", name), proc_macro2::Span::call_site());
let hydrate_fn_name =
syn::Ident::new(&format!("hydrate_{}", name), proc_macro2::Span::call_site());
let expanded = quote::quote! {
#[cfg(not(target_arch = "wasm32"))]
#vis fn #name(#(#arg_names: #arg_types),*) -> String {
let inner_html = {
#body
};
let props_json = (|| -> ::core::result::Result<String, serde_json::Error> {
let mut props = serde_json::Map::new();
#(
props.insert(
stringify!(#arg_names).to_string(),
serde_json::to_value(&#arg_names)?,
);
)*
::core::result::Result::Ok(serde_json::Value::Object(props).to_string())
})()
.unwrap_or_default();
let escaped_props = rullst::html::escape_str(&props_json);
format!(
"<div data-island=\"{}\" data-props=\"{}\">{}</div>",
stringify!(#name),
escaped_props,
inner_html
)
}
#[cfg(target_arch = "wasm32")]
#vis fn #name(#(#arg_names: #arg_types),*) -> String {
let Some(element) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.create_element("div").ok())
else {
return String::new();
};
let _ = {
#body
};
String::new()
}
#[cfg(target_arch = "wasm32")]
#[derive(serde::Deserialize)]
#[allow(non_camel_case_types)]
struct #props_struct_name {
#(#arg_names: #arg_types),*
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen]
#[allow(non_snake_case)]
pub fn #hydrate_fn_name(element: web_sys::Element, props_json: &str) {
let props: #props_struct_name = match serde_json::from_str(props_json) {
Ok(p) => p,
Err(_) => return,
};
#(let #arg_names = props.#arg_names;)*
let element = element;
let _ = {
#body
};
}
};
expanded.into()
}
#[proc_macro_attribute]
pub fn live_component(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input = item.into();
live_parser::parse_live_component(input).into()
}
#[proc_macro_attribute]
pub fn live_event(_attr: TokenStream, item: TokenStream) -> TokenStream {
item
}
#[proc_macro_attribute]
pub fn memoize(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input_fn = parse_macro_input!(item as syn::ItemFn);
expand_memoize(&input_fn).into()
}
fn expand_memoize(input_fn: &syn::ItemFn) -> proc_macro2::TokenStream {
let vis = &input_fn.vis;
let sig = &input_fn.sig;
let name = &sig.ident;
let body = &input_fn.block;
let output_type = match &sig.output {
syn::ReturnType::Default => quote::quote!(()),
syn::ReturnType::Type(_, ty) => quote::quote!(#ty),
};
let mut arg_names = Vec::new();
let mut arg_types = Vec::new();
for arg in &sig.inputs {
if let syn::FnArg::Typed(pat_type) = arg
&& let syn::Pat::Ident(pat_ident) = &*pat_type.pat
{
arg_names.push(&pat_ident.ident);
arg_types.push(&pat_type.ty);
}
}
quote::quote! {
#vis fn #name(#(#arg_names: #arg_types),*) -> #output_type {
let cache_key = (|| -> ::core::result::Result<serde_json::Value, serde_json::Error> {
::core::result::Result::Ok(serde_json::Value::Array(vec![
#(serde_json::to_value(&#arg_names)?),*
]))
})()
.ok()
.map(|arguments| {
format!(
"{}:{}",
concat!(
module_path!(),
"::",
stringify!(#name),
"@",
file!(),
":",
line!(),
":",
column!()
),
arguments
)
});
if let Some(cache_key) = &cache_key {
if let Some(cached) = rullst::cache::memory::get(cache_key) {
if let Ok(cached_str) = serde_json::from_str::<#output_type>(&cached) {
return cached_str;
}
}
}
let result: #output_type = { #body };
if let Some(cache_key) = &cache_key {
if let Ok(serialized) = serde_json::to_string(&result) {
rullst::cache::memory::set(cache_key, &serialized);
}
}
result
}
}
}
#[deprecated(
since = "12.0.0",
note = "use rullst::routes! for route registration; this legacy marker will be removed"
)]
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
if attr.is_empty() {
item
} else {
syn::Error::new(
proc_macro2::Span::call_site(),
"#[route] does not register paths; use rullst::routes! instead",
)
.into_compile_error()
.into()
}
}
#[proc_macro_attribute]
pub fn server_function(attr: TokenStream, item: TokenStream) -> TokenStream {
match server_function::expand(attr.into(), item.into()) {
Ok(expanded) => expanded.into(),
Err(error) => error.into_compile_error().into(),
}
}
#[proc_macro_attribute]
pub fn require_role(attr: TokenStream, item: TokenStream) -> TokenStream {
let role = parse_macro_input!(attr as syn::LitStr);
let input_fn = parse_macro_input!(item as syn::ItemFn);
match expand_require_role(&role, &input_fn) {
Ok(expanded) => expanded.into(),
Err(error) => error.to_compile_error().into(),
}
}
fn expand_require_role(
role: &syn::LitStr,
input_fn: &syn::ItemFn,
) -> Result<proc_macro2::TokenStream, syn::Error> {
if input_fn.sig.asyncness.is_none() {
return Err(syn::Error::new_spanned(
input_fn.sig.fn_token,
"#[require_role] supports async handlers only",
));
}
let role_value = role.value();
if role_value.trim().is_empty()
|| role_value.chars().count() > 128
|| role_value.chars().any(char::is_control)
{
return Err(syn::Error::new_spanned(
role,
"#[require_role] needs a non-empty role of at most 128 characters without controls",
));
}
let has_user = input_fn.sig.inputs.iter().any(|argument| match argument {
syn::FnArg::Receiver(_) => false,
syn::FnArg::Typed(argument) => pattern_binds_user(&argument.pat),
});
if !has_user {
return Err(syn::Error::new_spanned(
&input_fn.sig.inputs,
"#[require_role] requires a handler parameter that binds an authenticated `user`",
));
}
let attributes = &input_fn.attrs;
let vis = &input_fn.vis;
let mut signature = input_fn.sig.clone();
signature.output = syn::parse_quote!(-> rullst::response::Response);
let body = &input_fn.block;
let expanded = quote::quote! {
#(#attributes)*
#vis #signature {
if !rullst::auth::HasRole::has_role(&user, #role) {
return rullst::response::IntoResponse::into_response((
rullst::http::StatusCode::FORBIDDEN,
"Forbidden: Insufficient privileges",
));
}
let result = async move { #body }.await;
rullst::response::IntoResponse::into_response(result)
}
};
Ok(expanded)
}
fn pattern_binds_user(pattern: &syn::Pat) -> bool {
match pattern {
syn::Pat::Ident(binding) => {
binding.ident == "user"
|| binding
.subpat
.as_ref()
.is_some_and(|(_, pattern)| pattern_binds_user(pattern))
}
syn::Pat::Or(pattern) => pattern.cases.iter().any(pattern_binds_user),
syn::Pat::Paren(pattern) => pattern_binds_user(&pattern.pat),
syn::Pat::Reference(pattern) => pattern_binds_user(&pattern.pat),
syn::Pat::Slice(pattern) => pattern.elems.iter().any(pattern_binds_user),
syn::Pat::Struct(pattern) => pattern
.fields
.iter()
.any(|field| pattern_binds_user(&field.pat)),
syn::Pat::Tuple(pattern) => pattern.elems.iter().any(pattern_binds_user),
syn::Pat::TupleStruct(pattern) => pattern.elems.iter().any(pattern_binds_user),
syn::Pat::Type(pattern) => pattern_binds_user(&pattern.pat),
_ => false,
}
}
#[proc_macro_derive(Billable)]
pub fn derive_billable(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as syn::DeriveInput);
match expand_billable(&input) {
Ok(expanded) => expanded.into(),
Err(error) => error.into_compile_error().into(),
}
}
fn expand_billable(input: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let name = &input.ident;
let syn::Data::Struct(data_struct) = &input.data else {
return Err(syn::Error::new_spanned(
input,
"Billable can only be derived for a struct with named fields",
));
};
let syn::Fields::Named(fields) = &data_struct.fields else {
return Err(syn::Error::new_spanned(
&data_struct.fields,
"Billable requires a struct with a named `email: String` field",
));
};
let has_field = |expected: &str| {
fields
.named
.iter()
.any(|field| field.ident.as_ref().is_some_and(|ident| ident == expected))
};
if !has_field("email") {
return Err(syn::Error::new_spanned(
fields,
"Billable requires a named `email: String` field",
));
}
let sub_id_fn = if has_field("subscription_id") {
quote::quote! {
fn subscription_id(&self) -> Option<String> {
self.subscription_id.clone()
}
}
} else {
quote::quote! {}
};
let tier_fn = if has_field("tier") {
quote::quote! {
fn tier(&self) -> Option<String> {
self.tier.clone()
}
}
} else {
quote::quote! {}
};
let has_grace_start = has_field("grace_period_starts_at");
let has_grace_end = has_field("grace_period_ends_at");
if has_grace_start != has_grace_end {
return Err(syn::Error::new_spanned(
fields,
"Billable grace periods require both `grace_period_starts_at: Option<i64>` and `grace_period_ends_at: Option<i64>`",
));
}
let grace_period_fn = if has_grace_start {
quote::quote! {
fn grace_period(
&self,
) -> Result<Option<rullst::capital::GracePeriod>, rullst::capital::CapitalError> {
match (self.grace_period_starts_at, self.grace_period_ends_at) {
(Some(starts_at), Some(ends_at)) => {
rullst::capital::GracePeriod::new(starts_at, ends_at).map(Some)
}
(None, None) => Ok(None),
_ => Err(rullst::capital::CapitalError::SubscriptionError(
"grace period requires both start and end timestamps".to_string(),
)),
}
}
}
} else {
quote::quote! {}
};
let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
Ok(quote::quote! {
impl #impl_generics rullst::capital::Billable for #name #type_generics #where_clause {
fn email(&self) -> String {
self.email.clone()
}
#sub_id_fn
#tier_fn
#grace_period_fn
}
})
}