actus_controller_macros/lib.rs
1//! Procedural macros for the Actus controller system: the `#[controller]`
2//! attribute and the `app_routes!` macro. (The `routes!` macro that appears
3//! inside a `#[controller]` impl is a `macro_rules!` in `actus-controller`;
4//! `#[controller]` consumes the block it produces.)
5//!
6//! This crate is an implementation detail — depend on `actus` (or
7//! `actus-controller`) and use the macros through their prelude re-exports
8//! rather than depending on this crate directly.
9//!
10//! Supports HTTP verb constraints, path parameters (including a trailing
11//! `{...rest}`), strict/lax parameter modes, `prepare` hooks, per-controller
12//! `rate_limit` / `max_body_bytes`, and per-route docs sourced from each handler's
13//! `///` comment.
14#![warn(missing_docs)]
15
16use proc_macro::TokenStream;
17use quote::{ToTokens, quote};
18use std::collections::BTreeMap; // NEW: used to gather handler docs
19use syn::{
20 Expr, Ident, ImplItem, ItemImpl, LitStr, Token,
21 ext::IdentExt,
22 parse::{Parse, ParseStream},
23 parse_macro_input,
24 punctuated::Punctuated,
25};
26
27// =========================
28// Core types for route definitions
29// =========================
30
31struct AllRoutes {
32 routes: Vec<RouteDefinition>,
33}
34
35struct RouteDefinition {
36 verb: Option<Verb>,
37 pattern: LitStr,
38 handler: Ident,
39 params: Punctuated<Param, Token![,]>,
40}
41
42struct Param {
43 name: Ident,
44 ty: syn::Type,
45 default: Option<Expr>,
46}
47
48#[derive(Debug, Clone)]
49enum Verb {
50 GET,
51 POST,
52 PUT,
53 DELETE,
54 PATCH,
55 HEAD,
56 OPTIONS,
57}
58
59impl Verb {
60 fn from_ident(ident: &Ident) -> Option<Self> {
61 match ident.to_string().as_str() {
62 "GET" => Some(Verb::GET),
63 "POST" => Some(Verb::POST),
64 "PUT" => Some(Verb::PUT),
65 "DELETE" => Some(Verb::DELETE),
66 "PATCH" => Some(Verb::PATCH),
67 "HEAD" => Some(Verb::HEAD),
68 "OPTIONS" => Some(Verb::OPTIONS),
69 _ => None,
70 }
71 }
72
73 fn to_tokens(&self) -> proc_macro2::TokenStream {
74 match self {
75 Verb::GET => quote! { ::actus::__internal::Verb::GET },
76 Verb::POST => quote! { ::actus::__internal::Verb::POST },
77 Verb::PUT => quote! { ::actus::__internal::Verb::PUT },
78 Verb::DELETE => quote! { ::actus::__internal::Verb::DELETE },
79 Verb::PATCH => quote! { ::actus::__internal::Verb::PATCH },
80 Verb::HEAD => quote! { ::actus::__internal::Verb::HEAD },
81 Verb::OPTIONS => quote! { ::actus::__internal::Verb::OPTIONS },
82 }
83 }
84}
85
86// =========================
87// Controller attributes (strict/lax, prepare function)
88// =========================
89
90#[derive(Debug, Clone, Copy)]
91enum ControllerMode {
92 Strict,
93 Lax,
94}
95
96struct ControllerAttrs {
97 mode: ControllerMode,
98 prepare: Option<syn::ExprPath>,
99 /// `#[controller(max_body_bytes = <expr>)]` — per-controller maximum
100 /// buffered body, in bytes. `None` means inherit the server-level cap.
101 max_body_bytes: Option<syn::Expr>,
102 /// `#[controller(rate_limit = <expr>)]` — per-controller rate-limit
103 /// *class* label (an `&'static str`). `None` means the controller
104 /// declares no class. A label, not a policy: the application's
105 /// rate-limit middleware maps class → limits (see the `Controller`
106 /// trait's `actus_rate_limit` docs).
107 rate_limit: Option<syn::Expr>,
108 /// `#[controller(expects = <expr>)]` — the controller's declared caller
109 /// expectation (an `&'static str`), a *floor*: the least-privileged
110 /// caller it is written to accept. `None` means the controller declares
111 /// nothing — which is a visible row in `Router::mounts()`, not a skip.
112 /// A label, not a policy (see the `Controller` trait's `actus_expects`
113 /// docs).
114 expects: Option<syn::Expr>,
115}
116
117// =========================
118// Parser implementations
119// =========================
120
121impl Parse for AllRoutes {
122 fn parse(input: ParseStream) -> syn::Result<Self> {
123 let mut routes = Vec::new();
124
125 while !input.is_empty() {
126 // Reject the legacy `[Access::X]` section syntax with a clear
127 // pointer. Actus is now policy-agnostic; access decisions live
128 // in the application's `prepare` hook (and its policy layer).
129 if input.peek(syn::token::Bracket) {
130 let bracket_span = input.fork().parse::<proc_macro2::TokenTree>()?.span();
131 return Err(syn::Error::new(
132 bracket_span,
133 "actus no longer ships an `Access` enum or `[Access::*]` section syntax. \
134 Authorization belongs in your `#[controller(prepare = …)]` hook, where \
135 you can call into your own policy layer (e.g. `services::policy::*`).",
136 ));
137 }
138
139 // Check for optional HTTP verb prefix (e.g., GET, POST)
140 let verb = if input.peek2(LitStr) {
141 if let Ok(ident) = input.parse::<Ident>() {
142 if let Some(v) = Verb::from_ident(&ident) {
143 Some(v)
144 } else {
145 return Err(syn::Error::new(
146 ident.span(),
147 format!(
148 "Unknown HTTP verb: {}. Expected GET, POST, PUT, DELETE, PATCH, HEAD, or OPTIONS",
149 ident
150 ),
151 ));
152 }
153 } else {
154 None
155 }
156 } else {
157 None
158 };
159
160 // Parse the route pattern (e.g., "posts/{id}")
161 let pattern: LitStr = input.parse()?;
162 validate_pattern(&pattern)?;
163 input.parse::<Token![=>]>()?;
164 let handler: Ident = input.parse()?;
165
166 // Parse handler parameters
167 let params_content;
168 syn::parenthesized!(params_content in input);
169 let params = Punctuated::parse_terminated(¶ms_content)?;
170
171 routes.push(RouteDefinition {
172 verb,
173 pattern,
174 handler,
175 params,
176 });
177
178 if input.peek(Token![,]) {
179 input.parse::<Token![,]>()?;
180 }
181 }
182
183 Ok(AllRoutes { routes })
184 }
185}
186
187impl Parse for Param {
188 fn parse(input: ParseStream) -> syn::Result<Self> {
189 let name: Ident = input.parse()?;
190 input.parse::<Token![:]>()?;
191 let ty: syn::Type = input.parse()?;
192
193 let default = if input.peek(Token![=]) {
194 input.parse::<Token![=]>()?;
195 Some(input.parse()?)
196 } else {
197 None
198 };
199
200 Ok(Param { name, ty, default })
201 }
202}
203
204impl Parse for ControllerAttrs {
205 fn parse(input: ParseStream) -> syn::Result<Self> {
206 let mut mode = ControllerMode::Strict;
207 let mut prepare = None;
208 let mut max_body_bytes = None;
209 let mut rate_limit = None;
210 let mut expects = None;
211
212 while !input.is_empty() {
213 let ident: Ident = input.parse()?;
214 match ident.to_string().as_str() {
215 "strict" => mode = ControllerMode::Strict,
216 "lax" => mode = ControllerMode::Lax,
217 "prepare" => {
218 input.parse::<Token![=]>()?;
219 prepare = Some(input.parse()?);
220 }
221 "max_body_bytes" => {
222 input.parse::<Token![=]>()?;
223 // Accept any expression — a literal (`4096`), a const
224 // reference (`MAX_BODY`), or an arithmetic expression
225 // (`4 * 1024`). Resolved at handler-build time, so
226 // const-fn / static const are both fine.
227 max_body_bytes = Some(input.parse()?);
228 }
229 "rate_limit" => {
230 input.parse::<Token![=]>()?;
231 // Accept any expression that evaluates to `&'static str` —
232 // a string literal (`"auth"`) is the common case; a const
233 // reference (`AUTH_CLASS`) works too. It's a *label*, not a
234 // policy: the app's rate-limit middleware maps it to limits.
235 rate_limit = Some(input.parse()?);
236 }
237 "expects" => {
238 input.parse::<Token![=]>()?;
239 // Same contract as `rate_limit`: any expression evaluating
240 // to `&'static str`. The value is opaque to the framework —
241 // a floor the application's coverage check and gate read.
242 expects = Some(input.parse()?);
243 }
244 _ => {
245 return Err(syn::Error::new(
246 ident.span(),
247 "Expected 'strict', 'lax', 'prepare = <fn>', 'max_body_bytes = <expr>', \
248 'rate_limit = <expr>', or 'expects = <expr>'",
249 ));
250 }
251 }
252
253 if input.peek(Token![,]) {
254 input.parse::<Token![,]>()?;
255 }
256 }
257
258 Ok(ControllerAttrs {
259 mode,
260 prepare,
261 max_body_bytes,
262 rate_limit,
263 expects,
264 })
265 }
266}
267
268// =========================
269// Helper functions
270// =========================
271
272fn type_to_string(ty: &syn::Type) -> String {
273 quote!(#ty).to_string().replace(" ", "")
274}
275
276fn extract_path_params(pattern: &str) -> Vec<String> {
277 let mut params = Vec::new();
278 let mut chars = pattern.chars().peekable();
279
280 while let Some(ch) = chars.next() {
281 if ch == '{' {
282 let mut param = String::new();
283 for ch in chars.by_ref() {
284 if ch == '}' {
285 break;
286 }
287 param.push(ch);
288 }
289 // `{...name}` is a "rest" parameter (captures the path remainder);
290 // its handler-side binding is just `name`. `{name}` is unchanged.
291 let name = param.strip_prefix("...").unwrap_or(param.as_str());
292 if !name.is_empty() {
293 params.push(name.to_string());
294 }
295 }
296 }
297
298 params
299}
300
301/// If `segment` is a well-formed `{...name}` rest token, returns `Some(name)`.
302/// Returns `None` for ordinary `{name}` tokens and for literals.
303fn rest_param_name(segment: &str) -> Option<&str> {
304 segment
305 .strip_prefix("{...")
306 .and_then(|s| s.strip_suffix('}'))
307 .filter(|name| !name.is_empty())
308}
309
310/// Validate a route pattern at macro-expansion time. Enforces the rules the
311/// runtime matcher ([`actus_controller::routing::match_pattern`]) relies on:
312/// a `{...name}` rest parameter, if present, must be the *last* `/`-segment,
313/// must appear at most once, and must have a non-empty name. Also rejects the
314/// near-miss `{..name}` / `{...}` shapes with a pointed message.
315fn validate_pattern(pattern: &LitStr) -> syn::Result<()> {
316 let value = pattern.value();
317 let segments: Vec<&str> = value.split('/').collect();
318
319 for (i, seg) in segments.iter().enumerate() {
320 // Only consider segments that look like a single `{...}` token.
321 let Some(inner) = seg.strip_prefix('{').and_then(|s| s.strip_suffix('}')) else {
322 continue;
323 };
324
325 if !inner.starts_with('.') {
326 continue; // ordinary `{name}` token — nothing to check here.
327 }
328
329 // It starts with a dot, so the author meant a rest parameter.
330 if rest_param_name(seg).is_none() {
331 return Err(syn::Error::new(
332 pattern.span(),
333 format!(
334 "malformed rest parameter `{{{inner}}}` in route pattern `{value}`; \
335 write it as `{{...name}}` (three dots, then a non-empty name)"
336 ),
337 ));
338 }
339
340 if i != segments.len() - 1 {
341 return Err(syn::Error::new(
342 pattern.span(),
343 format!(
344 "rest parameter `{{{inner}}}` must be the last segment of route \
345 pattern `{value}` (it captures the entire remaining path)"
346 ),
347 ));
348 }
349
350 // Last segment and well-formed; make sure it's the only one. (Any
351 // earlier rest token would already have errored on the position
352 // check above, so reaching here twice is impossible — but a literal
353 // earlier segment that merely *contains* `{...}` text wouldn't, so
354 // be explicit about "at most one".)
355 let earlier_rest = segments[..i]
356 .iter()
357 .filter(|s| rest_param_name(s).is_some())
358 .count();
359 if earlier_rest > 0 {
360 return Err(syn::Error::new(
361 pattern.span(),
362 format!("route pattern `{value}` has more than one `{{...name}}` rest parameter"),
363 ));
364 }
365 }
366
367 Ok(())
368}
369
370// NEW: Collect `///` docs from methods in the impl and join them by newlines.
371fn collect_method_docs(item_impl: &syn::ItemImpl) -> BTreeMap<String, String> {
372 use syn::{Attribute, ImplItem, Meta};
373
374 fn doc_from_attrs(attrs: &[Attribute]) -> String {
375 attrs
376 .iter()
377 .filter(|a| a.path().is_ident("doc"))
378 .filter_map(|a| {
379 match &a.meta {
380 Meta::NameValue(nv) => {
381 // #[doc = "..."] → nv.value is an Expr
382 if let syn::Expr::Lit(expr_lit) = &nv.value
383 && let syn::Lit::Str(ls) = &expr_lit.lit
384 {
385 return Some(ls.value());
386 }
387 None
388 }
389 _ => None,
390 }
391 })
392 .collect::<Vec<_>>()
393 .join("\n")
394 }
395
396 let mut map = BTreeMap::new();
397 for it in &item_impl.items {
398 if let ImplItem::Fn(m) = it {
399 let name = m.sig.ident.to_string();
400 let doc = doc_from_attrs(&m.attrs);
401 if !doc.trim().is_empty() {
402 map.insert(name, doc);
403 }
404 }
405 }
406 map
407}
408
409// =========================
410// Main macro entry point
411// =========================
412
413/// Attribute macro for a controller's `impl` block.
414///
415/// Reads the `routes! { … }` block inside the impl and generates the
416/// controller's `Controller` implementation — its route table, parameter
417/// extraction, and dispatch. Attribute options: `prepare = Self::method` (a
418/// hook run before every handler in the controller), `lax` (relax strict
419/// parameter rejection), `rate_limit = "class"` (stamp a rate-limit class onto
420/// matched requests), `max_body_bytes = N` (per-controller request-body cap, in
421/// bytes), and `expects = "floor"` (declare the least-privileged caller this
422/// controller accepts — surfaced by `Router::mounts()` for route-family
423/// coverage checks).
424#[proc_macro_attribute]
425pub fn controller(attr: TokenStream, item: TokenStream) -> TokenStream {
426 // Parse attributes (strict/lax mode, prepare function)
427 let attrs = if attr.is_empty() {
428 ControllerAttrs {
429 mode: ControllerMode::Strict,
430 prepare: None,
431 max_body_bytes: None,
432 rate_limit: None,
433 expects: None,
434 }
435 } else {
436 match syn::parse::<ControllerAttrs>(attr) {
437 Ok(a) => a,
438 Err(e) => return e.to_compile_error().into(),
439 }
440 };
441
442 let item_impl = parse_macro_input!(item as ItemImpl);
443
444 // NEW: collect method docs (by handler name)
445 let docs_map = collect_method_docs(&item_impl);
446
447 // Find the routes! macro inside the impl block
448 let routes_macro = item_impl
449 .items
450 .iter()
451 .find_map(|item| {
452 if let ImplItem::Macro(m) = item
453 && m.mac.path.is_ident("routes") {
454 return Some(m);
455 }
456 None
457 })
458 .expect("A `routes!` macro invocation is required inside an `impl` block marked with `#[controller]`");
459
460 // Parse the routes
461 let all_routes: AllRoutes = match syn::parse2(routes_macro.mac.tokens.clone()) {
462 Ok(routes) => routes,
463 Err(e) => return e.to_compile_error().into(),
464 };
465
466 // Generate code (passing docs_map)
467 let generated = generate_controller_impl(&item_impl, &all_routes, &attrs, &docs_map);
468
469 generated.into()
470}
471
472// =========================
473// Code generation
474// =========================
475
476fn generate_controller_impl(
477 item_impl: &ItemImpl,
478 all_routes: &AllRoutes,
479 attrs: &ControllerAttrs,
480 docs_map: &BTreeMap<String, String>, // NEW
481) -> proc_macro2::TokenStream {
482 let self_ty = &item_impl.self_ty;
483
484 // Generate route definitions and handler dispatch arms
485 let mut route_defs = Vec::new();
486 let mut handler_arms = Vec::new();
487
488 for (idx, route) in all_routes.routes.iter().enumerate() {
489 let pattern = &route.pattern;
490 let pattern_str = pattern.value();
491 let handler = &route.handler;
492 let handler_id = format!("handler_{}", idx);
493
494 // Extract path parameters from the pattern
495 let path_params = extract_path_params(&pattern_str);
496 // The (at most one) `{...name}` rest parameter, if the pattern has one.
497 let rest_param: Option<String> = pattern_str
498 .split('/')
499 .find_map(|s| rest_param_name(s).map(str::to_string));
500
501 // Build parameter definitions and extraction code
502 let mut param_defs = Vec::new();
503 let mut param_extractions = Vec::new();
504 let mut param_names = Vec::new();
505
506 for param in &route.params {
507 let name = ¶m.name;
508 // The *wire* name (query key / path-segment name) is the bare
509 // identifier — `r#`-strip raw identifiers so a handler can bind a
510 // keyword-named parameter (`r#type: Vec<String>` reads `?type=`).
511 // The handler-call identifier (`param_names`) keeps the `r#`.
512 let name_str = name.unraw().to_string();
513 let ty_str = type_to_string(¶m.ty);
514
515 // Collect parameter names for handler call
516 param_names.push(name.clone());
517
518 // Special pass-through: a handler may declare `_: &Params` to
519 // receive a borrow of the per-request `Params` (typically to
520 // read state stashed by a `prepare` hook via `params.insert`).
521 // No `ParamDef` is emitted for this — it's framework plumbing,
522 // not a request input.
523 //
524 // Naming-collision note: if a route's pattern has a `{name}`
525 // capture *and* the handler also declares `name: &Params`, the
526 // `&Params` short-circuit wins — the path capture is silently
527 // discarded. Don't name a `&Params` binding the same as a path
528 // token. (The reader, not the compiler, has to catch it.)
529 if ty_str == "&Params" {
530 param_extractions.push(quote! { ¶ms });
531 continue;
532 }
533
534 let is_rest = rest_param.as_deref() == Some(name_str.as_str());
535
536 // A `{...name}` rest parameter always carries the joined path
537 // remainder, so it must be typed `String`. (A `{name}` segment
538 // can be `u64`/`u32`/… because it's a single segment; the rest
539 // token can't.)
540 if is_rest && ty_str != "String" {
541 let msg = format!(
542 "rest parameter `{{...{name_str}}}` must be typed `String` (it holds the \
543 joined remaining path); found `{ty_str}`"
544 );
545 param_defs.push(quote! { compile_error!(#msg) });
546 param_extractions.push(quote! { compile_error!(#msg) });
547 continue;
548 }
549
550 // Determine parameter source (path, query, or body)
551 let source = if path_params.contains(&name_str) {
552 quote! { ::actus::__internal::ParamSource::Path }
553 } else if ty_str == "JsonValue" || ty_str == "Bytes" {
554 quote! { ::actus::__internal::ParamSource::Body }
555 } else {
556 quote! { ::actus::__internal::ParamSource::Query }
557 };
558
559 // Generate parameter type and default value
560 let (param_type, default_value) =
561 generate_param_type_and_default(&ty_str, ¶m.default);
562
563 param_defs.push(quote! {
564 ::actus::__internal::ParamDef {
565 name: #name_str,
566 ty: #param_type,
567 source: #source,
568 default: #default_value,
569 }
570 });
571
572 // Generate extraction code for this parameter
573 let extraction = generate_param_extraction(&name_str, &ty_str, ¶m.default);
574 param_extractions.push(extraction);
575 }
576
577 // Build route definition. `RouteDef.verb` is `&'static [Verb]`:
578 // a single-element slice for an explicit verb, or
579 // `DEFAULT_VERBS` (= [GET, POST]) for an unmarked route.
580 let verb_expr = match &route.verb {
581 Some(v) => {
582 let verb_tokens = v.to_tokens();
583 quote! { &[#verb_tokens] }
584 }
585 None => quote! { ::actus::__internal::DEFAULT_VERBS },
586 };
587
588 // NEW: look up handler docs and attach to RouteDef
589 let handler_name_str = handler.to_string();
590 let doc_val = docs_map.get(&handler_name_str).cloned().unwrap_or_default();
591 let doc_lit = syn::LitStr::new(&doc_val, proc_macro2::Span::call_site());
592
593 route_defs.push(quote! {
594 ::actus::__internal::RouteDef {
595 pattern: #pattern_str,
596 handler_id: #handler_id,
597 handler: #handler_name_str,
598 verb: #verb_expr,
599 params: &[ #(#param_defs),* ],
600 doc: if #doc_lit.is_empty() { None } else { Some(#doc_lit) },
601 }
602 });
603
604 // Build handler dispatch arm
605 handler_arms.push(quote! {
606 #handler_id => {
607 #(let #param_names = #param_extractions;)*
608 self.#handler(#(#param_names),*).await
609 }
610 });
611 }
612
613 // Generate prepare function call if specified.
614 //
615 // Signature contract:
616 // async fn prepare(&self, route: &RouteDef, params: &mut Params)
617 // -> Result<Option<ReplyData>, WebError>;
618 //
619 // - `Ok(None)` continues to the handler.
620 // - `Ok(Some(reply))` short-circuits with that reply (any HTTP status the
621 // hook chose).
622 // - `Err(WebError::*)` short-circuits with the corresponding error response.
623 //
624 // We pass `&mut params` so the hook can both *read* the request (headers,
625 // body, undeclared query params) and *attach* per-request state via
626 // `params.insert(...)` for handlers to read via a `&Params` parameter.
627 let prepare_call = attrs
628 .prepare
629 .as_ref()
630 .map(|prepare_fn| {
631 quote! {
632 if let ::core::option::Option::Some(__actus_early_reply) =
633 #prepare_fn(self, &matched_route, &mut params).await?
634 {
635 return ::core::result::Result::Ok(__actus_early_reply);
636 }
637 }
638 })
639 .unwrap_or_default();
640
641 // Generate mode configuration
642 let mode_value = match attrs.mode {
643 ControllerMode::Strict => quote! { ::actus::__internal::ControllerMode::Strict },
644 ControllerMode::Lax => quote! { ::actus::__internal::ControllerMode::Lax },
645 };
646
647 let mode_str = match attrs.mode {
648 ControllerMode::Strict => "strict",
649 ControllerMode::Lax => "lax",
650 };
651
652 let _ = mode_str;
653
654 // The prepare hook needs `&mut params` so it can stash per-request state
655 // for handlers via `params.insert(...)`. When no prepare is configured,
656 // omit `mut` to avoid an "unused_mut" warning in the user's crate.
657 let params_binding = if attrs.prepare.is_some() {
658 quote! { mut params: ::actus::__internal::Params }
659 } else {
660 quote! { params: ::actus::__internal::Params }
661 };
662
663 // `#[controller(max_body_bytes = …)]` — emit an `actus_max_body_bytes` override
664 // returning `Some(<expr>)`. When not set, the trait's default impl
665 // returns `None` and the server falls back to its own cap.
666 let max_body_bytes_impl = attrs.max_body_bytes.as_ref().map(|expr| {
667 quote! {
668 fn actus_max_body_bytes(&self) -> ::core::option::Option<usize> {
669 ::core::option::Option::Some(#expr)
670 }
671 }
672 });
673
674 // `#[controller(rate_limit = "class")]` — emit an `actus_rate_limit`
675 // override returning `Some("class")`. When not set, the trait's default
676 // impl returns `None` (the controller declares no rate-limit class). The
677 // server stamps this label onto the matched request so an application's
678 // rate-limit middleware can read it; the framework owns no policy.
679 let rate_limit_impl = attrs.rate_limit.as_ref().map(|expr| {
680 quote! {
681 fn actus_rate_limit(&self) -> ::core::option::Option<&'static str> {
682 ::core::option::Option::Some(#expr)
683 }
684 }
685 });
686
687 // `#[controller(expects = "floor")]` — emit an `actus_expects` override
688 // returning `Some("floor")`. When not set, the trait's default impl
689 // returns `None`, and `Router::mounts()` reports the absence as a row —
690 // absence being representable is the point of the route-family design.
691 let expects_impl = attrs.expects.as_ref().map(|expr| {
692 quote! {
693 fn actus_expects(&self) -> ::core::option::Option<&'static str> {
694 ::core::option::Option::Some(#expr)
695 }
696 }
697 });
698
699 // `#[controller(prepare = Self::auth)]` — additionally surface the hook's
700 // *presence* (and its written path, for route dumps) via `actus_prepare`,
701 // so a route-family coverage check can enforce rules like "a credential
702 // floor requires a hook". The hook itself is still compiled directly into
703 // `actus_dispatch` above; this is introspection, not an invocation handle.
704 let prepare_impl = attrs.prepare.as_ref().map(|prepare_fn| {
705 // Same tokens-to-text idiom as `type_to_string`: `quote!` needs no
706 // `ToTokens` import, and stripping spaces turns the token-stream
707 // rendering `Self :: auth` into the written form `Self::auth`.
708 let path_str = quote!(#prepare_fn).to_string().replace(' ', "");
709 let path_lit = syn::LitStr::new(&path_str, proc_macro2::Span::call_site());
710 quote! {
711 fn actus_prepare(&self) -> ::core::option::Option<&'static str> {
712 ::core::option::Option::Some(#path_lit)
713 }
714 }
715 });
716
717 // Generate main Controller trait implementation
718 let controller_impl = quote! {
719 #[::actus::__internal::async_trait]
720 impl ::actus::__internal::Controller for #self_ty {
721 async fn actus_dispatch(&self, action: &str, #params_binding) -> ::actus::__internal::Reply {
722 // Define routes as static data inside the method
723 // This works with dyn Controller since it's not an associated const
724 static ROUTES: &[::actus::__internal::RouteDef] = &[ #(#route_defs),* ];
725
726 // Use shared routing utilities to resolve the route
727 let (matched_route, extracted) = ::actus::__internal::routing::resolve(
728 ROUTES,
729 action,
730 ¶ms,
731 #mode_value
732 )?;
733
734 // Call prepare function if configured
735 #prepare_call
736
737 // Type-safe dispatch to handlers. `resolve` only ever returns
738 // a route from `ROUTES`, and every route there has a matching
739 // arm below (both are keyed by the macro-assigned handler id),
740 // so the catch-all is genuinely unreachable — `match` on `&str`
741 // just can't prove it.
742 match matched_route.handler_id {
743 #(#handler_arms),*
744 other => ::core::unreachable!(
745 "dispatch: no handler for route id {:?}", other
746 ),
747 }
748 }
749
750 fn __name(&self) -> &'static str {
751 stringify!(#self_ty)
752 }
753
754 /// Returns the static route definitions for this controller.
755 /// Useful for introspection (e.g., generating API documentation).
756 fn actus_describe_routes(&self) -> Vec<::actus::__internal::RouteDef> {
757 static ROUTES: &[::actus::__internal::RouteDef] = &[ #(#route_defs),* ];
758 ROUTES.to_vec()
759 }
760
761 #max_body_bytes_impl
762 #rate_limit_impl
763 #expects_impl
764 #prepare_impl
765 }
766 };
767
768 quote! {
769 // Original impl block unchanged
770 #item_impl
771
772 // Generated Controller implementation
773 #controller_impl
774 }
775}
776
777fn generate_param_type_and_default(
778 ty_str: &str,
779 default: &Option<Expr>,
780) -> (proc_macro2::TokenStream, proc_macro2::TokenStream) {
781 match (ty_str, default) {
782 ("String", Some(d)) => (
783 quote! { ::actus::__internal::ParamType::String },
784 quote! { Some(::actus::__internal::ParamDefault::String(#d)) },
785 ),
786 ("String", None) => (
787 quote! { ::actus::__internal::ParamType::String },
788 quote! { None },
789 ),
790 ("i64", Some(d)) => (
791 quote! { ::actus::__internal::ParamType::Int },
792 quote! { Some(::actus::__internal::ParamDefault::Int(#d)) },
793 ),
794 ("i64", None) => (
795 quote! { ::actus::__internal::ParamType::Int },
796 quote! { None },
797 ),
798 ("u64", Some(d)) => (
799 quote! { ::actus::__internal::ParamType::U64 },
800 quote! { Some(::actus::__internal::ParamDefault::U64(#d)) },
801 ),
802 ("u64", None) => (
803 quote! { ::actus::__internal::ParamType::U64 },
804 quote! { None },
805 ),
806 ("u32", Some(d)) => (
807 quote! { ::actus::__internal::ParamType::U32 },
808 quote! { Some(::actus::__internal::ParamDefault::U32(#d)) },
809 ),
810 ("u32", None) => (
811 quote! { ::actus::__internal::ParamType::U32 },
812 quote! { None },
813 ),
814 ("f64", Some(d)) => (
815 quote! { ::actus::__internal::ParamType::F64 },
816 quote! { Some(::actus::__internal::ParamDefault::F64(#d)) },
817 ),
818 ("f64", None) => (
819 quote! { ::actus::__internal::ParamType::F64 },
820 quote! { None },
821 ),
822 ("bool", Some(d)) => (
823 quote! { ::actus::__internal::ParamType::Bool },
824 quote! { Some(::actus::__internal::ParamDefault::Bool(#d)) },
825 ),
826 ("bool", None) => (
827 quote! { ::actus::__internal::ParamType::Bool },
828 quote! { None },
829 ),
830 ("Vec<String>", _) => (
831 quote! { ::actus::__internal::ParamType::StringArray },
832 quote! { None },
833 ),
834 ("JsonValue", _) => (
835 quote! { ::actus::__internal::ParamType::Json },
836 quote! { None },
837 ),
838 ("Bytes", _) => (
839 quote! { ::actus::__internal::ParamType::Bytes },
840 quote! { None },
841 ),
842 _ => (
843 quote! { compile_error!(concat!("Unsupported type: ", #ty_str)) },
844 quote! { None },
845 ),
846 }
847}
848
849fn generate_param_extraction(
850 name_str: &str,
851 ty_str: &str,
852 default: &Option<Expr>,
853) -> proc_macro2::TokenStream {
854 match (ty_str, default) {
855 ("String", Some(d)) => {
856 quote! {
857 extracted.get_string(#name_str)
858 .unwrap_or_else(|_| #d.to_string())
859 }
860 }
861 ("String", None) => {
862 quote! { extracted.get_string(#name_str)? }
863 }
864 ("i64", Some(d)) => {
865 quote! {
866 extracted.get_i64(#name_str).unwrap_or(#d)
867 }
868 }
869 ("i64", None) => {
870 quote! { extracted.get_i64(#name_str)? }
871 }
872 ("u64", Some(d)) => {
873 quote! {
874 extracted.get_u64(#name_str).unwrap_or(#d)
875 }
876 }
877 ("u64", None) => {
878 quote! { extracted.get_u64(#name_str)? }
879 }
880 ("u32", Some(d)) => {
881 quote! {
882 extracted.get_u32(#name_str).unwrap_or(#d)
883 }
884 }
885 ("u32", None) => {
886 quote! { extracted.get_u32(#name_str)? }
887 }
888 ("f64", Some(d)) => {
889 quote! {
890 extracted.get_f64(#name_str).unwrap_or(#d)
891 }
892 }
893 ("f64", None) => {
894 quote! { extracted.get_f64(#name_str)? }
895 }
896 ("bool", Some(d)) => {
897 quote! {
898 extracted.get_bool(#name_str).unwrap_or(#d)
899 }
900 }
901 ("bool", None) => {
902 quote! { extracted.get_bool(#name_str)? }
903 }
904 ("Vec<String>", _) => {
905 quote! { extracted.get_string_array(#name_str)? }
906 }
907 ("JsonValue", _) => {
908 quote! { extracted.get_json_body()? }
909 }
910 // Raw request-body bytes. Use for binary uploads (e.g. `.uwx`
911 // packages). The framework discriminates JSON/form/binary at
912 // ingest by `Content-Type`; declaring `body: Bytes` is the
913 // signal that this handler wants the unparsed payload.
914 ("Bytes", _) => {
915 quote! { extracted.get_body_bytes() }
916 }
917 _ => {
918 quote! { compile_error!(concat!("Unsupported type: ", #ty_str)) }
919 }
920 }
921}
922
923// =========================
924// app_routes! — application-level route map with deps + per-route service injection
925// =========================
926//
927// Grammar:
928//
929// app_routes! {
930// // Optional. The `deps(...)` parens declare *inputs* — values
931// // constructed by the caller (typically in `main()`) and passed
932// // into the generated `init()` function. The brace block is the
933// // `let`-block of dependencies built inside `init()`.
934// deps(store: Arc<Store>) {
935// cache = Cache::redis(...).await?,
936// }
937// routes {
938// "api/entities" => EntityController { store },
939// "api/cache" => CacheController { cache },
940// "health" => HealthController,
941// "*" => SpaController,
942// }
943// }
944//
945// Generates `pub async fn init(<inputs>) -> actus::InitResult<actus::Router>`,
946// where `InitResult<T> = Result<T, anyhow::Error>` — `?` on any error type
947// implementing `std::error::Error + Send + Sync + 'static` works inside.
948// The `deps` block is optional; the `(<inputs>)` clause inside it is
949// optional too. All four shapes are valid:
950//
951// deps { ... } // only let-bindings
952// deps(a: T, b: U) { ... } // both inputs and let-bindings
953// deps(a: T, b: U) {} // only inputs
954// // (no deps block at all) // neither
955//
956// In each route's controller construction, struct-literal shorthand
957// (`{ store, cache }`) and rest-spread (`..base`) are auto-cloned, since
958// deps and inputs are typically `Arc`-wrapped and shared across multiple
959// controllers. Non-struct-literal expressions pass through unchanged.
960
961struct AppRoutesInput {
962 inputs: Vec<InputParam>,
963 deps: Vec<DepBinding>,
964 routes: Vec<RouteBinding>,
965}
966
967struct InputParam {
968 name: Ident,
969 ty: syn::Type,
970}
971
972struct DepBinding {
973 name: Ident,
974 value: Expr,
975}
976
977struct RouteBinding {
978 path: LitStr,
979 construction: Expr,
980}
981
982impl Parse for AppRoutesInput {
983 fn parse(input: ParseStream) -> syn::Result<Self> {
984 let mut inputs: Vec<InputParam> = Vec::new();
985 let mut deps: Vec<DepBinding> = Vec::new();
986 let mut routes: Option<Vec<RouteBinding>> = None;
987
988 while !input.is_empty() {
989 let kw: Ident = input.parse()?;
990 let kw_str = kw.to_string();
991
992 match kw_str.as_str() {
993 "deps" => {
994 // Optional `(name: Type, ...)` declaring init() inputs.
995 if input.peek(syn::token::Paren) {
996 let paren_content;
997 syn::parenthesized!(paren_content in input);
998 while !paren_content.is_empty() {
999 let name: Ident = paren_content.parse()?;
1000 paren_content.parse::<Token![:]>()?;
1001 let ty: syn::Type = paren_content.parse()?;
1002 inputs.push(InputParam { name, ty });
1003 if !paren_content.is_empty() {
1004 paren_content.parse::<Token![,]>()?;
1005 }
1006 }
1007 }
1008 // Then the `{ name = expr, ... }` block of let bindings
1009 // (may be empty).
1010 let content;
1011 syn::braced!(content in input);
1012 while !content.is_empty() {
1013 let name: Ident = content.parse()?;
1014 content.parse::<Token![=]>()?;
1015 let value: Expr = content.parse()?;
1016 deps.push(DepBinding { name, value });
1017 if !content.is_empty() {
1018 content.parse::<Token![,]>()?;
1019 }
1020 }
1021 }
1022 "routes" => {
1023 let content;
1024 syn::braced!(content in input);
1025 let mut rs = Vec::new();
1026 while !content.is_empty() {
1027 let path: LitStr = content.parse()?;
1028 content.parse::<Token![=>]>()?;
1029 let construction: Expr = content.parse()?;
1030 rs.push(RouteBinding { path, construction });
1031 if !content.is_empty() {
1032 content.parse::<Token![,]>()?;
1033 }
1034 }
1035 routes = Some(rs);
1036 }
1037 other => {
1038 return Err(syn::Error::new(
1039 kw.span(),
1040 format!("expected 'deps' or 'routes', got '{}'", other),
1041 ));
1042 }
1043 }
1044 }
1045
1046 let routes = routes.ok_or_else(|| {
1047 syn::Error::new(
1048 proc_macro2::Span::call_site(),
1049 "app_routes! requires a 'routes { ... }' block",
1050 )
1051 })?;
1052
1053 Ok(Self {
1054 inputs,
1055 deps,
1056 routes,
1057 })
1058 }
1059}
1060
1061/// Declares the application's URL blueprint and generates its `init()`.
1062///
1063/// Takes an optional `deps( … ) { … }` block — constructor-injected services
1064/// and `let`-bindings shared across controllers — and a `routes { mount =>
1065/// Controller … }` map. Expands to an async `init(…)` returning the built
1066/// `Router`: it constructs every controller, wires its dependencies, and
1067/// registers each mount. See the `actus` crate's top-level docs for a worked
1068/// example.
1069#[proc_macro]
1070pub fn app_routes(input: TokenStream) -> TokenStream {
1071 let parsed = parse_macro_input!(input as AppRoutesInput);
1072 generate_app_routes(parsed).into()
1073}
1074
1075fn generate_app_routes(parsed: AppRoutesInput) -> proc_macro2::TokenStream {
1076 let init_params = parsed.inputs.iter().map(|p| {
1077 let name = &p.name;
1078 let ty = &p.ty;
1079 quote! { #name: #ty }
1080 });
1081
1082 let dep_lets = parsed.deps.iter().map(|d| {
1083 let name = &d.name;
1084 let value = &d.value;
1085 quote! { let #name = #value; }
1086 });
1087
1088 let route_calls = parsed.routes.iter().map(|r| {
1089 let path = &r.path;
1090 let construction = rewrite_construction(&r.construction);
1091 quote! {
1092 .add_route(#path, ::std::sync::Arc::new(#construction))
1093 }
1094 });
1095
1096 quote! {
1097 pub async fn init(#(#init_params),*) -> ::actus::InitResult<::actus::Router> {
1098 #(#dep_lets)*
1099
1100 let router = ::actus::RouterBuilder::new()
1101 #(#route_calls)*
1102 .build();
1103
1104 ::std::result::Result::Ok(router)
1105 }
1106 }
1107}
1108
1109/// In a struct-literal controller construction, auto-clone simple references
1110/// to bound names so the same value can be threaded into multiple
1111/// controllers without each call site spelling `.clone()`.
1112///
1113/// Three cases get auto-cloned, all gated on the right-hand side being a
1114/// bare unqualified identifier (no path segments, no generic args, no
1115/// `qself`). The escape hatch in every case is the same: write any
1116/// non-ident expression — method call, function call, qualified path, an
1117/// already-`.clone()`d value — and it passes through unchanged.
1118///
1119/// * **Shorthand** — `Foo { db }` → `Foo { db: db.clone() }`.
1120/// * **Bare-ident explicit form** — `Foo { svc: store }` →
1121/// `Foo { svc: store.clone() }`.
1122/// * **Bare-ident rest spread** — `Foo { ..base }` → `Foo { ..(base).clone() }`.
1123/// Non-ident rest expressions (`..base.clone()`, `..self.template()`)
1124/// pass through verbatim — no double-cloning.
1125fn rewrite_construction(expr: &Expr) -> proc_macro2::TokenStream {
1126 let Expr::Struct(s) = expr else {
1127 return expr.to_token_stream();
1128 };
1129
1130 let path = &s.path;
1131 let mut inner = proc_macro2::TokenStream::new();
1132 let mut wrote_field = false;
1133
1134 for f in s.fields.iter() {
1135 if wrote_field {
1136 inner.extend(quote! { , });
1137 }
1138 wrote_field = true;
1139
1140 let member = &f.member;
1141 if f.colon_token.is_none() {
1142 // Shorthand: `name` → `name: name.clone()`
1143 inner.extend(quote! { #member: #member.clone() });
1144 } else if is_bare_ident(&f.expr) {
1145 // Explicit `target: source` where `source` is a simple ident:
1146 // treat like shorthand and auto-clone. Any non-ident expression
1147 // (method call, function call, qualified path, …) passes
1148 // through unchanged so callers retain a clean escape hatch.
1149 let value = &f.expr;
1150 inner.extend(quote! { #member: #value.clone() });
1151 } else {
1152 let value = &f.expr;
1153 inner.extend(quote! { #member: #value });
1154 }
1155 }
1156
1157 if let Some(rest) = &s.rest {
1158 if wrote_field {
1159 inner.extend(quote! { , });
1160 }
1161 if is_bare_ident(rest) {
1162 inner.extend(quote! { ..(#rest).clone() });
1163 } else {
1164 inner.extend(quote! { ..#rest });
1165 }
1166 }
1167
1168 quote! { #path { #inner } }
1169}
1170
1171/// Whether `expr` is a single, unqualified identifier path (no qself, no
1172/// leading `::`, exactly one segment, no generic args). The criterion the
1173/// auto-clone rule uses to decide that an explicit field assignment looks
1174/// "shorthand-like."
1175fn is_bare_ident(expr: &Expr) -> bool {
1176 let Expr::Path(p) = expr else { return false };
1177 p.qself.is_none()
1178 && p.path.leading_colon.is_none()
1179 && p.path.segments.len() == 1
1180 && p.path.segments[0].arguments.is_none()
1181}