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 // …and the compile-time half: the marker trait `app_routes!`'s `families`
700 // block requires of every controller under a covered prefix. Emitted ONLY
701 // when `expects` is declared — its absence is what makes an undeclared
702 // controller on a covered lane fail to compile. `expects` must therefore
703 // be a `const` expression (a string literal or a `const` path), which is
704 // also what makes the family's accepted-set check possible in a `const`.
705 let declares_expectation_impl = attrs.expects.as_ref().map(|expr| {
706 quote! {
707 impl ::actus::__internal::DeclaresExpectation for #self_ty {
708 const EXPECTS: &'static str = #expr;
709 }
710 }
711 });
712
713 // `#[controller(prepare = Self::auth)]` — additionally surface the hook's
714 // *presence* (and its written path, for route dumps) via `actus_prepare`,
715 // so a route-family coverage check can enforce rules like "a credential
716 // floor requires a hook". The hook itself is still compiled directly into
717 // `actus_dispatch` above; this is introspection, not an invocation handle.
718 let prepare_impl = attrs.prepare.as_ref().map(|prepare_fn| {
719 // Same tokens-to-text idiom as `type_to_string`: `quote!` needs no
720 // `ToTokens` import, and stripping spaces turns the token-stream
721 // rendering `Self :: auth` into the written form `Self::auth`.
722 let path_str = quote!(#prepare_fn).to_string().replace(' ', "");
723 let path_lit = syn::LitStr::new(&path_str, proc_macro2::Span::call_site());
724 quote! {
725 fn actus_prepare(&self) -> ::core::option::Option<&'static str> {
726 ::core::option::Option::Some(#path_lit)
727 }
728 }
729 });
730
731 // Generate main Controller trait implementation
732 let controller_impl = quote! {
733 #[::actus::__internal::async_trait]
734 impl ::actus::__internal::Controller for #self_ty {
735 async fn actus_dispatch(&self, action: &str, #params_binding) -> ::actus::__internal::Reply {
736 // Define routes as static data inside the method
737 // This works with dyn Controller since it's not an associated const
738 static ROUTES: &[::actus::__internal::RouteDef] = &[ #(#route_defs),* ];
739
740 // Use shared routing utilities to resolve the route
741 let (matched_route, extracted) = ::actus::__internal::routing::resolve(
742 ROUTES,
743 action,
744 ¶ms,
745 #mode_value
746 )?;
747
748 // Call prepare function if configured
749 #prepare_call
750
751 // Type-safe dispatch to handlers. `resolve` only ever returns
752 // a route from `ROUTES`, and every route there has a matching
753 // arm below (both are keyed by the macro-assigned handler id),
754 // so the catch-all is genuinely unreachable — `match` on `&str`
755 // just can't prove it.
756 match matched_route.handler_id {
757 #(#handler_arms),*
758 other => ::core::unreachable!(
759 "dispatch: no handler for route id {:?}", other
760 ),
761 }
762 }
763
764 fn __name(&self) -> &'static str {
765 stringify!(#self_ty)
766 }
767
768 /// Returns the static route definitions for this controller.
769 /// Useful for introspection (e.g., generating API documentation).
770 fn actus_describe_routes(&self) -> Vec<::actus::__internal::RouteDef> {
771 static ROUTES: &[::actus::__internal::RouteDef] = &[ #(#route_defs),* ];
772 ROUTES.to_vec()
773 }
774
775 #max_body_bytes_impl
776 #rate_limit_impl
777 #expects_impl
778 #prepare_impl
779 }
780 };
781
782 quote! {
783 // Original impl block unchanged
784 #item_impl
785
786 #declares_expectation_impl
787
788 // Generated Controller implementation
789 #controller_impl
790 }
791}
792
793fn generate_param_type_and_default(
794 ty_str: &str,
795 default: &Option<Expr>,
796) -> (proc_macro2::TokenStream, proc_macro2::TokenStream) {
797 match (ty_str, default) {
798 ("String", Some(d)) => (
799 quote! { ::actus::__internal::ParamType::String },
800 quote! { Some(::actus::__internal::ParamDefault::String(#d)) },
801 ),
802 ("String", None) => (
803 quote! { ::actus::__internal::ParamType::String },
804 quote! { None },
805 ),
806 ("i64", Some(d)) => (
807 quote! { ::actus::__internal::ParamType::Int },
808 quote! { Some(::actus::__internal::ParamDefault::Int(#d)) },
809 ),
810 ("i64", None) => (
811 quote! { ::actus::__internal::ParamType::Int },
812 quote! { None },
813 ),
814 ("u64", Some(d)) => (
815 quote! { ::actus::__internal::ParamType::U64 },
816 quote! { Some(::actus::__internal::ParamDefault::U64(#d)) },
817 ),
818 ("u64", None) => (
819 quote! { ::actus::__internal::ParamType::U64 },
820 quote! { None },
821 ),
822 ("u32", Some(d)) => (
823 quote! { ::actus::__internal::ParamType::U32 },
824 quote! { Some(::actus::__internal::ParamDefault::U32(#d)) },
825 ),
826 ("u32", None) => (
827 quote! { ::actus::__internal::ParamType::U32 },
828 quote! { None },
829 ),
830 ("f64", Some(d)) => (
831 quote! { ::actus::__internal::ParamType::F64 },
832 quote! { Some(::actus::__internal::ParamDefault::F64(#d)) },
833 ),
834 ("f64", None) => (
835 quote! { ::actus::__internal::ParamType::F64 },
836 quote! { None },
837 ),
838 ("bool", Some(d)) => (
839 quote! { ::actus::__internal::ParamType::Bool },
840 quote! { Some(::actus::__internal::ParamDefault::Bool(#d)) },
841 ),
842 ("bool", None) => (
843 quote! { ::actus::__internal::ParamType::Bool },
844 quote! { None },
845 ),
846 ("Vec<String>", _) => (
847 quote! { ::actus::__internal::ParamType::StringArray },
848 quote! { None },
849 ),
850 ("JsonValue", _) => (
851 quote! { ::actus::__internal::ParamType::Json },
852 quote! { None },
853 ),
854 ("Bytes", _) => (
855 quote! { ::actus::__internal::ParamType::Bytes },
856 quote! { None },
857 ),
858 _ => (
859 quote! { compile_error!(concat!("Unsupported type: ", #ty_str)) },
860 quote! { None },
861 ),
862 }
863}
864
865fn generate_param_extraction(
866 name_str: &str,
867 ty_str: &str,
868 default: &Option<Expr>,
869) -> proc_macro2::TokenStream {
870 match (ty_str, default) {
871 ("String", Some(d)) => {
872 quote! {
873 extracted.get_string(#name_str)
874 .unwrap_or_else(|_| #d.to_string())
875 }
876 }
877 ("String", None) => {
878 quote! { extracted.get_string(#name_str)? }
879 }
880 ("i64", Some(d)) => {
881 quote! {
882 extracted.get_i64(#name_str).unwrap_or(#d)
883 }
884 }
885 ("i64", None) => {
886 quote! { extracted.get_i64(#name_str)? }
887 }
888 ("u64", Some(d)) => {
889 quote! {
890 extracted.get_u64(#name_str).unwrap_or(#d)
891 }
892 }
893 ("u64", None) => {
894 quote! { extracted.get_u64(#name_str)? }
895 }
896 ("u32", Some(d)) => {
897 quote! {
898 extracted.get_u32(#name_str).unwrap_or(#d)
899 }
900 }
901 ("u32", None) => {
902 quote! { extracted.get_u32(#name_str)? }
903 }
904 ("f64", Some(d)) => {
905 quote! {
906 extracted.get_f64(#name_str).unwrap_or(#d)
907 }
908 }
909 ("f64", None) => {
910 quote! { extracted.get_f64(#name_str)? }
911 }
912 ("bool", Some(d)) => {
913 quote! {
914 extracted.get_bool(#name_str).unwrap_or(#d)
915 }
916 }
917 ("bool", None) => {
918 quote! { extracted.get_bool(#name_str)? }
919 }
920 ("Vec<String>", _) => {
921 quote! { extracted.get_string_array(#name_str)? }
922 }
923 ("JsonValue", _) => {
924 quote! { extracted.get_json_body()? }
925 }
926 // Raw request-body bytes. Use for binary uploads (e.g. `.uwx`
927 // packages). The framework discriminates JSON/form/binary at
928 // ingest by `Content-Type`; declaring `body: Bytes` is the
929 // signal that this handler wants the unparsed payload.
930 ("Bytes", _) => {
931 quote! { extracted.get_body_bytes() }
932 }
933 _ => {
934 quote! { compile_error!(concat!("Unsupported type: ", #ty_str)) }
935 }
936 }
937}
938
939// =========================
940// app_routes! — application-level route map with deps + per-route service injection
941// =========================
942//
943// Grammar:
944//
945// app_routes! {
946// // Optional. The `deps(...)` parens declare *inputs* — values
947// // constructed by the caller (typically in `main()`) and passed
948// // into the generated `init()` function. The brace block is the
949// // `let`-block of dependencies built inside `init()`.
950// deps(store: Arc<Store>) {
951// cache = Cache::redis(...).await?,
952// }
953// routes {
954// "api/entities" => EntityController { store },
955// "api/cache" => CacheController { cache },
956// "health" => HealthController,
957// "*" => SpaController,
958// }
959// }
960//
961// Generates `pub async fn init(<inputs>) -> actus::InitResult<actus::Router>`,
962// where `InitResult<T> = Result<T, anyhow::Error>` — `?` on any error type
963// implementing `std::error::Error + Send + Sync + 'static` works inside.
964// The `deps` block is optional; the `(<inputs>)` clause inside it is
965// optional too. All four shapes are valid:
966//
967// deps { ... } // only let-bindings
968// deps(a: T, b: U) { ... } // both inputs and let-bindings
969// deps(a: T, b: U) {} // only inputs
970// // (no deps block at all) // neither
971//
972// In each route's controller construction, struct-literal shorthand
973// (`{ store, cache }`) and rest-spread (`..base`) are auto-cloned, since
974// deps and inputs are typically `Arc`-wrapped and shared across multiple
975// controllers. Non-struct-literal expressions pass through unchanged.
976
977struct AppRoutesInput {
978 inputs: Vec<InputParam>,
979 deps: Vec<DepBinding>,
980 routes: Vec<RouteBinding>,
981 /// `families { "api", "hooks" => ["signature"], … }` — mount prefixes whose
982 /// controllers must declare `expects`, each optionally with the floors the
983 /// family accepts. Empty when the block is absent.
984 families: Vec<FamilyDecl>,
985}
986
987struct FamilyDecl {
988 prefix: LitStr,
989 /// `None` = presence only; `Some(list)` = presence + membership.
990 accepts: Option<Vec<LitStr>>,
991}
992
993struct InputParam {
994 name: Ident,
995 ty: syn::Type,
996}
997
998struct DepBinding {
999 name: Ident,
1000 value: Expr,
1001}
1002
1003struct RouteBinding {
1004 path: LitStr,
1005 construction: Expr,
1006}
1007
1008impl Parse for AppRoutesInput {
1009 fn parse(input: ParseStream) -> syn::Result<Self> {
1010 let mut inputs: Vec<InputParam> = Vec::new();
1011 let mut deps: Vec<DepBinding> = Vec::new();
1012 let mut routes: Option<Vec<RouteBinding>> = None;
1013 let mut families: Vec<FamilyDecl> = Vec::new();
1014
1015 while !input.is_empty() {
1016 let kw: Ident = input.parse()?;
1017 let kw_str = kw.to_string();
1018
1019 match kw_str.as_str() {
1020 "deps" => {
1021 // Optional `(name: Type, ...)` declaring init() inputs.
1022 if input.peek(syn::token::Paren) {
1023 let paren_content;
1024 syn::parenthesized!(paren_content in input);
1025 while !paren_content.is_empty() {
1026 let name: Ident = paren_content.parse()?;
1027 paren_content.parse::<Token![:]>()?;
1028 let ty: syn::Type = paren_content.parse()?;
1029 inputs.push(InputParam { name, ty });
1030 if !paren_content.is_empty() {
1031 paren_content.parse::<Token![,]>()?;
1032 }
1033 }
1034 }
1035 // Then the `{ name = expr, ... }` block of let bindings
1036 // (may be empty).
1037 let content;
1038 syn::braced!(content in input);
1039 while !content.is_empty() {
1040 let name: Ident = content.parse()?;
1041 content.parse::<Token![=]>()?;
1042 let value: Expr = content.parse()?;
1043 deps.push(DepBinding { name, value });
1044 if !content.is_empty() {
1045 content.parse::<Token![,]>()?;
1046 }
1047 }
1048 }
1049 "families" => {
1050 // `families { "api", "public" => ["anonymous"], … }`
1051 let content;
1052 syn::braced!(content in input);
1053 while !content.is_empty() {
1054 let prefix: LitStr = content.parse()?;
1055 let accepts = if content.peek(Token![=>]) {
1056 content.parse::<Token![=>]>()?;
1057 let list;
1058 syn::bracketed!(list in content);
1059 let floors: Punctuated<LitStr, Token![,]> =
1060 Punctuated::parse_terminated(&list)?;
1061 if floors.is_empty() {
1062 return Err(syn::Error::new(
1063 prefix.span(),
1064 "a family's accepted-floor list must name at least one floor \
1065 (or omit `=> [...]` to require only that a floor be declared)",
1066 ));
1067 }
1068 Some(floors.into_iter().collect())
1069 } else {
1070 None
1071 };
1072 families.push(FamilyDecl { prefix, accepts });
1073 if !content.is_empty() {
1074 content.parse::<Token![,]>()?;
1075 }
1076 }
1077 }
1078 "routes" => {
1079 let content;
1080 syn::braced!(content in input);
1081 let mut rs = Vec::new();
1082 while !content.is_empty() {
1083 let path: LitStr = content.parse()?;
1084 content.parse::<Token![=>]>()?;
1085 let construction: Expr = content.parse()?;
1086 rs.push(RouteBinding { path, construction });
1087 if !content.is_empty() {
1088 content.parse::<Token![,]>()?;
1089 }
1090 }
1091 routes = Some(rs);
1092 }
1093 other => {
1094 return Err(syn::Error::new(
1095 kw.span(),
1096 format!("expected 'deps', 'families' or 'routes', got '{}'", other),
1097 ));
1098 }
1099 }
1100 }
1101
1102 let routes = routes.ok_or_else(|| {
1103 syn::Error::new(
1104 proc_macro2::Span::call_site(),
1105 "app_routes! requires a 'routes { ... }' block",
1106 )
1107 })?;
1108
1109 Ok(Self {
1110 inputs,
1111 deps,
1112 routes,
1113 families,
1114 })
1115 }
1116}
1117
1118/// A mount path's segments, normalised the way `RouterBuilder::add_route`
1119/// normalises them: surrounding slashes trimmed, a trailing `*` (the
1120/// catch-all sugar) dropped — so `"api/*"` and `"api"` cover the same tree.
1121fn mount_segments(path: &str) -> Vec<String> {
1122 let mut segs: Vec<String> = path
1123 .trim_matches('/')
1124 .split('/')
1125 .filter(|s| !s.is_empty())
1126 .map(String::from)
1127 .collect();
1128 if segs.last().is_some_and(|s| s == "*") {
1129 segs.pop();
1130 }
1131 segs
1132}
1133
1134/// Declares the application's URL blueprint and generates its `init()`.
1135///
1136/// Takes an optional `deps( … ) { … }` block — constructor-injected services
1137/// and `let`-bindings shared across controllers — and a `routes { mount =>
1138/// Controller … }` map. Expands to an async `init(…)` returning the built
1139/// `Router`: it constructs every controller, wires its dependencies, and
1140/// registers each mount. See the `actus` crate's top-level docs for a worked
1141/// example.
1142#[proc_macro]
1143pub fn app_routes(input: TokenStream) -> TokenStream {
1144 let parsed = parse_macro_input!(input as AppRoutesInput);
1145 generate_app_routes(parsed).into()
1146}
1147
1148fn generate_app_routes(parsed: AppRoutesInput) -> proc_macro2::TokenStream {
1149 let init_params = parsed.inputs.iter().map(|p| {
1150 let name = &p.name;
1151 let ty = &p.ty;
1152 quote! { #name: #ty }
1153 });
1154
1155 let dep_lets = parsed.deps.iter().map(|d| {
1156 let name = &d.name;
1157 let value = &d.value;
1158 quote! { let #name = #value; }
1159 });
1160
1161 // Route families: which family (if any) covers each mount. Longest
1162 // covering prefix wins, mirroring longest-prefix routing, so a deeper
1163 // family entry can carve a subtree out of a shallower one. A family that
1164 // covers no mount is a compile error at its literal — a typo there would
1165 // otherwise constrain nothing, which is the failure the block exists to
1166 // prevent, one level up.
1167 let family_segs: Vec<Vec<String>> = parsed
1168 .families
1169 .iter()
1170 .map(|f| mount_segments(&f.prefix.value()))
1171 .collect();
1172 let mut family_used = vec![false; parsed.families.len()];
1173 let mut covering: Vec<Option<usize>> = Vec::with_capacity(parsed.routes.len());
1174 for r in &parsed.routes {
1175 let segs = mount_segments(&r.path.value());
1176 let best = family_segs
1177 .iter()
1178 .enumerate()
1179 .filter(|(_, fs)| {
1180 segs.len() >= fs.len() && segs.iter().zip(fs.iter()).all(|(a, b)| a == b)
1181 })
1182 .max_by_key(|(_, fs)| fs.len())
1183 .map(|(i, _)| i);
1184 if let Some(i) = best {
1185 family_used[i] = true;
1186 }
1187 covering.push(best);
1188 }
1189 for (i, f) in parsed.families.iter().enumerate() {
1190 if !family_used[i] {
1191 return syn::Error::new(
1192 f.prefix.span(),
1193 format!(
1194 "route family `{}` covers no mount in this `routes` block — a family that \
1195 constrains nothing is usually a typo; fix the prefix, or remove the entry \
1196 until its first controller is mounted",
1197 f.prefix.value()
1198 ),
1199 )
1200 .to_compile_error();
1201 }
1202 }
1203
1204 // One zero-sized `Family` type per entry that names accepted floors, so the
1205 // membership check can run in a `const` inside the generic pass-through.
1206 let family_types: Vec<proc_macro2::TokenStream> = parsed
1207 .families
1208 .iter()
1209 .enumerate()
1210 .filter_map(|(i, f)| {
1211 let accepts = f.accepts.as_ref()?;
1212 let ident = quote::format_ident!("__ActusFamily{}", i);
1213 Some(quote! {
1214 struct #ident;
1215 impl ::actus::__internal::Family for #ident {
1216 const ACCEPTS: &'static [&'static str] = &[ #(#accepts),* ];
1217 }
1218 })
1219 })
1220 .collect();
1221
1222 let route_calls = parsed.routes.iter().zip(covering.iter()).map(|(r, cover)| {
1223 let path = &r.path;
1224 let construction = rewrite_construction(&r.construction);
1225 let construction = match cover {
1226 None => construction,
1227 Some(i) if parsed.families[*i].accepts.is_some() => {
1228 let ident = quote::format_ident!("__ActusFamily{}", i);
1229 quote! { ::actus::__internal::declares_expectation_in::<#ident, _>(#construction) }
1230 }
1231 Some(_) => quote! { ::actus::__internal::declares_expectation(#construction) },
1232 };
1233 quote! {
1234 .add_route(#path, ::std::sync::Arc::new(#construction))
1235 }
1236 });
1237
1238 quote! {
1239 pub async fn init(#(#init_params),*) -> ::actus::InitResult<::actus::Router> {
1240 #(#family_types)*
1241 #(#dep_lets)*
1242
1243 let router = ::actus::RouterBuilder::new()
1244 #(#route_calls)*
1245 .build();
1246
1247 ::std::result::Result::Ok(router)
1248 }
1249 }
1250}
1251
1252/// In a struct-literal controller construction, auto-clone simple references
1253/// to bound names so the same value can be threaded into multiple
1254/// controllers without each call site spelling `.clone()`.
1255///
1256/// Three cases get auto-cloned, all gated on the right-hand side being a
1257/// bare unqualified identifier (no path segments, no generic args, no
1258/// `qself`). The escape hatch in every case is the same: write any
1259/// non-ident expression — method call, function call, qualified path, an
1260/// already-`.clone()`d value — and it passes through unchanged.
1261///
1262/// * **Shorthand** — `Foo { db }` → `Foo { db: db.clone() }`.
1263/// * **Bare-ident explicit form** — `Foo { svc: store }` →
1264/// `Foo { svc: store.clone() }`.
1265/// * **Bare-ident rest spread** — `Foo { ..base }` → `Foo { ..(base).clone() }`.
1266/// Non-ident rest expressions (`..base.clone()`, `..self.template()`)
1267/// pass through verbatim — no double-cloning.
1268fn rewrite_construction(expr: &Expr) -> proc_macro2::TokenStream {
1269 let Expr::Struct(s) = expr else {
1270 return expr.to_token_stream();
1271 };
1272
1273 let path = &s.path;
1274 let mut inner = proc_macro2::TokenStream::new();
1275 let mut wrote_field = false;
1276
1277 for f in s.fields.iter() {
1278 if wrote_field {
1279 inner.extend(quote! { , });
1280 }
1281 wrote_field = true;
1282
1283 let member = &f.member;
1284 if f.colon_token.is_none() {
1285 // Shorthand: `name` → `name: name.clone()`
1286 inner.extend(quote! { #member: #member.clone() });
1287 } else if is_bare_ident(&f.expr) {
1288 // Explicit `target: source` where `source` is a simple ident:
1289 // treat like shorthand and auto-clone. Any non-ident expression
1290 // (method call, function call, qualified path, …) passes
1291 // through unchanged so callers retain a clean escape hatch.
1292 let value = &f.expr;
1293 inner.extend(quote! { #member: #value.clone() });
1294 } else {
1295 let value = &f.expr;
1296 inner.extend(quote! { #member: #value });
1297 }
1298 }
1299
1300 if let Some(rest) = &s.rest {
1301 if wrote_field {
1302 inner.extend(quote! { , });
1303 }
1304 if is_bare_ident(rest) {
1305 inner.extend(quote! { ..(#rest).clone() });
1306 } else {
1307 inner.extend(quote! { ..#rest });
1308 }
1309 }
1310
1311 quote! { #path { #inner } }
1312}
1313
1314/// Whether `expr` is a single, unqualified identifier path (no qself, no
1315/// leading `::`, exactly one segment, no generic args). The criterion the
1316/// auto-clone rule uses to decide that an explicit field assignment looks
1317/// "shorthand-like."
1318fn is_bare_ident(expr: &Expr) -> bool {
1319 let Expr::Path(p) = expr else { return false };
1320 p.qself.is_none()
1321 && p.path.leading_colon.is_none()
1322 && p.path.segments.len() == 1
1323 && p.path.segments[0].arguments.is_none()
1324}