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#[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]
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 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 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]
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#[proc_macro_attribute]
173pub fn live_event(_attr: TokenStream, item: TokenStream) -> TokenStream {
174 item
175}
176
177#[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 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 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 if let Some(cache_key) = &cache_key {
250 if let Some(cached) = rullst::cache::memory::get(cache_key) {
251 if let Ok(cached_str) = serde_json::from_str::<#output_type>(&cached) {
254 return cached_str;
255 }
256 }
257 }
258
259 let result: #output_type = { #body };
261
262 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#[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#[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}