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 under a covered prefix 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 // ⛔ `get_bool_optional`, NOT `get_bool`. Absence is `Ok(false)` for a bool — the
913 // one type whose "missing" is a usable value — so `get_bool(..).unwrap_or(d)`
914 // unwraps that `false` and the declared default is DEAD CODE. Every other
915 // type reaches its default because `require_scalar` errors on absence.
916 // Measured 2026-09-04: a cancel route declaring `at_period_end: bool = true`
917 // cancelled immediately when the client omitted the parameter.
918 ("bool", Some(d)) => {
919 quote! {
920 extracted.get_bool_optional(#name_str)?.unwrap_or(#d)
921 }
922 }
923 ("bool", None) => {
924 quote! { extracted.get_bool(#name_str)? }
925 }
926 ("Vec<String>", _) => {
927 quote! { extracted.get_string_array(#name_str)? }
928 }
929 ("JsonValue", _) => {
930 quote! { extracted.get_json_body()? }
931 }
932 // Raw request-body bytes. Use for binary uploads (e.g. `.uwx`
933 // packages). The framework discriminates JSON/form/binary at
934 // ingest by `Content-Type`; declaring `body: Bytes` is the
935 // signal that this handler wants the unparsed payload.
936 ("Bytes", _) => {
937 quote! { extracted.get_body_bytes() }
938 }
939 _ => {
940 quote! { compile_error!(concat!("Unsupported type: ", #ty_str)) }
941 }
942 }
943}
944
945// =========================
946// app_routes! — application-level route map with deps + per-route service injection
947// =========================
948//
949// Grammar:
950//
951// app_routes! {
952// // Optional. The `deps(...)` parens declare *inputs* — values
953// // constructed by the caller (typically in `main()`) and passed
954// // into the generated `init()` function. The brace block is the
955// // `let`-block of dependencies built inside `init()`.
956// deps(store: Arc<Store>) {
957// cache = Cache::redis(...).await?,
958// }
959// routes {
960// "api/entities" => EntityController { store },
961// "api/cache" => CacheController { cache },
962// "health" => HealthController,
963// "*" => SpaController,
964// }
965// }
966//
967// Generates `pub async fn init(<inputs>) -> actus::InitResult<actus::Router>`,
968// where `InitResult<T> = Result<T, anyhow::Error>` — `?` on any error type
969// implementing `std::error::Error + Send + Sync + 'static` works inside.
970// The `deps` block is optional; the `(<inputs>)` clause inside it is
971// optional too. All four shapes are valid:
972//
973// deps { ... } // only let-bindings
974// deps(a: T, b: U) { ... } // both inputs and let-bindings
975// deps(a: T, b: U) {} // only inputs
976// // (no deps block at all) // neither
977//
978// In each route's controller construction, struct-literal shorthand
979// (`{ store, cache }`) and rest-spread (`..base`) are auto-cloned, since
980// deps and inputs are typically `Arc`-wrapped and shared across multiple
981// controllers. Non-struct-literal expressions pass through unchanged.
982
983struct AppRoutesInput {
984 inputs: Vec<InputParam>,
985 deps: Vec<DepBinding>,
986 routes: Vec<RouteBinding>,
987 /// `families { "api", "hooks" => ["signature"], … }` — mount prefixes whose
988 /// controllers must declare `expects`, each optionally with the floors the
989 /// family accepts. Empty when the block is absent.
990 families: Vec<FamilyDecl>,
991}
992
993struct FamilyDecl {
994 prefix: LitStr,
995 /// `None` = presence only; `Some(list)` = presence + membership.
996 accepts: Option<Vec<LitStr>>,
997}
998
999struct InputParam {
1000 name: Ident,
1001 ty: syn::Type,
1002}
1003
1004struct DepBinding {
1005 name: Ident,
1006 value: Expr,
1007}
1008
1009struct RouteBinding {
1010 path: LitStr,
1011 construction: Expr,
1012}
1013
1014impl Parse for AppRoutesInput {
1015 fn parse(input: ParseStream) -> syn::Result<Self> {
1016 let mut inputs: Vec<InputParam> = Vec::new();
1017 let mut deps: Vec<DepBinding> = Vec::new();
1018 let mut routes: Option<Vec<RouteBinding>> = None;
1019 let mut families: Vec<FamilyDecl> = Vec::new();
1020
1021 while !input.is_empty() {
1022 let kw: Ident = input.parse()?;
1023 let kw_str = kw.to_string();
1024
1025 match kw_str.as_str() {
1026 "deps" => {
1027 // Optional `(name: Type, ...)` declaring init() inputs.
1028 if input.peek(syn::token::Paren) {
1029 let paren_content;
1030 syn::parenthesized!(paren_content in input);
1031 while !paren_content.is_empty() {
1032 let name: Ident = paren_content.parse()?;
1033 paren_content.parse::<Token![:]>()?;
1034 let ty: syn::Type = paren_content.parse()?;
1035 inputs.push(InputParam { name, ty });
1036 if !paren_content.is_empty() {
1037 paren_content.parse::<Token![,]>()?;
1038 }
1039 }
1040 }
1041 // Then the `{ name = expr, ... }` block of let bindings
1042 // (may be empty).
1043 let content;
1044 syn::braced!(content in input);
1045 while !content.is_empty() {
1046 let name: Ident = content.parse()?;
1047 content.parse::<Token![=]>()?;
1048 let value: Expr = content.parse()?;
1049 deps.push(DepBinding { name, value });
1050 if !content.is_empty() {
1051 content.parse::<Token![,]>()?;
1052 }
1053 }
1054 }
1055 "families" => {
1056 // `families { "api", "public" => ["anonymous"], … }`
1057 let content;
1058 syn::braced!(content in input);
1059 while !content.is_empty() {
1060 let prefix: LitStr = content.parse()?;
1061 let accepts = if content.peek(Token![=>]) {
1062 content.parse::<Token![=>]>()?;
1063 let list;
1064 syn::bracketed!(list in content);
1065 let floors: Punctuated<LitStr, Token![,]> =
1066 Punctuated::parse_terminated(&list)?;
1067 if floors.is_empty() {
1068 return Err(syn::Error::new(
1069 prefix.span(),
1070 "a family's accepted-floor list must name at least one floor \
1071 (or omit `=> [...]` to require only that a floor be declared)",
1072 ));
1073 }
1074 Some(floors.into_iter().collect())
1075 } else {
1076 None
1077 };
1078 families.push(FamilyDecl { prefix, accepts });
1079 if !content.is_empty() {
1080 content.parse::<Token![,]>()?;
1081 }
1082 }
1083 }
1084 "routes" => {
1085 let content;
1086 syn::braced!(content in input);
1087 let mut rs = Vec::new();
1088 while !content.is_empty() {
1089 let path: LitStr = content.parse()?;
1090 content.parse::<Token![=>]>()?;
1091 let construction: Expr = content.parse()?;
1092 rs.push(RouteBinding { path, construction });
1093 if !content.is_empty() {
1094 content.parse::<Token![,]>()?;
1095 }
1096 }
1097 routes = Some(rs);
1098 }
1099 other => {
1100 return Err(syn::Error::new(
1101 kw.span(),
1102 format!("expected 'deps', 'families' or 'routes', got '{}'", other),
1103 ));
1104 }
1105 }
1106 }
1107
1108 let routes = routes.ok_or_else(|| {
1109 syn::Error::new(
1110 proc_macro2::Span::call_site(),
1111 "app_routes! requires a 'routes { ... }' block",
1112 )
1113 })?;
1114
1115 Ok(Self {
1116 inputs,
1117 deps,
1118 routes,
1119 families,
1120 })
1121 }
1122}
1123
1124/// A mount path's segments, normalised the way `RouterBuilder::add_route`
1125/// normalises them: surrounding slashes trimmed, a trailing `*` (the
1126/// catch-all sugar) dropped — so `"api/*"` and `"api"` cover the same tree.
1127///
1128/// ⚠️ Keep in step with `actus_controller::routing::family_segments` /
1129/// `covering_family`, the runtime twin consumers call from their boot-time
1130/// checks (a proc-macro crate cannot depend on the runtime crate, so the rule
1131/// is written twice; the crate-level nesting doctest pins that they agree).
1132fn mount_segments(path: &str) -> Vec<String> {
1133 let mut segs: Vec<String> = path
1134 .trim_matches('/')
1135 .split('/')
1136 .filter(|s| !s.is_empty())
1137 .map(String::from)
1138 .collect();
1139 if segs.last().is_some_and(|s| s == "*") {
1140 segs.pop();
1141 }
1142 segs
1143}
1144
1145/// Declares the application's URL blueprint and generates its `init()`.
1146///
1147/// Takes an optional `deps( … ) { … }` block — constructor-injected services
1148/// and `let`-bindings shared across controllers — and a `routes { mount =>
1149/// Controller … }` map. Expands to an async `init(…)` returning the built
1150/// `Router`: it constructs every controller, wires its dependencies, and
1151/// registers each mount. See the `actus` crate's top-level docs for a worked
1152/// example.
1153#[proc_macro]
1154pub fn app_routes(input: TokenStream) -> TokenStream {
1155 let parsed = parse_macro_input!(input as AppRoutesInput);
1156 generate_app_routes(parsed).into()
1157}
1158
1159fn generate_app_routes(parsed: AppRoutesInput) -> proc_macro2::TokenStream {
1160 let init_params = parsed.inputs.iter().map(|p| {
1161 let name = &p.name;
1162 let ty = &p.ty;
1163 quote! { #name: #ty }
1164 });
1165
1166 let dep_lets = parsed.deps.iter().map(|d| {
1167 let name = &d.name;
1168 let value = &d.value;
1169 quote! { let #name = #value; }
1170 });
1171
1172 // Route families: which family (if any) covers each mount. Longest
1173 // covering prefix wins, mirroring longest-prefix routing, so a deeper
1174 // family entry can carve a subtree out of a shallower one. A family that
1175 // covers no mount is a compile error at its literal — a typo there would
1176 // otherwise constrain nothing, which is the failure the block exists to
1177 // prevent, one level up.
1178 let family_segs: Vec<Vec<String>> = parsed
1179 .families
1180 .iter()
1181 .map(|f| mount_segments(&f.prefix.value()))
1182 .collect();
1183 let mut family_used = vec![false; parsed.families.len()];
1184 let mut covering: Vec<Option<usize>> = Vec::with_capacity(parsed.routes.len());
1185 for r in &parsed.routes {
1186 let segs = mount_segments(&r.path.value());
1187 let best = family_segs
1188 .iter()
1189 .enumerate()
1190 .filter(|(_, fs)| {
1191 segs.len() >= fs.len() && segs.iter().zip(fs.iter()).all(|(a, b)| a == b)
1192 })
1193 .max_by_key(|(_, fs)| fs.len())
1194 .map(|(i, _)| i);
1195 if let Some(i) = best {
1196 family_used[i] = true;
1197 }
1198 covering.push(best);
1199 }
1200 for (i, f) in parsed.families.iter().enumerate() {
1201 if !family_used[i] {
1202 return syn::Error::new(
1203 f.prefix.span(),
1204 format!(
1205 "route family `{}` covers no mount in this `routes` block — a family that \
1206 constrains nothing is usually a typo; fix the prefix, or remove the entry \
1207 until its first controller is mounted",
1208 f.prefix.value()
1209 ),
1210 )
1211 .to_compile_error();
1212 }
1213 }
1214
1215 // One zero-sized `Family` type per entry that names accepted floors, so the
1216 // membership check can run in a `const` inside the generic pass-through.
1217 let family_types: Vec<proc_macro2::TokenStream> = parsed
1218 .families
1219 .iter()
1220 .enumerate()
1221 .filter_map(|(i, f)| {
1222 let accepts = f.accepts.as_ref()?;
1223 let ident = quote::format_ident!("__ActusFamily{}", i);
1224 Some(quote! {
1225 struct #ident;
1226 impl ::actus::__internal::Family for #ident {
1227 const ACCEPTS: &'static [&'static str] = &[ #(#accepts),* ];
1228 }
1229 })
1230 })
1231 .collect();
1232
1233 let route_calls = parsed.routes.iter().zip(covering.iter()).map(|(r, cover)| {
1234 let path = &r.path;
1235 let construction = rewrite_construction(&r.construction);
1236 let construction = match cover {
1237 None => construction,
1238 Some(i) if parsed.families[*i].accepts.is_some() => {
1239 let ident = quote::format_ident!("__ActusFamily{}", i);
1240 quote! { ::actus::__internal::declares_expectation_in::<#ident, _>(#construction) }
1241 }
1242 Some(_) => quote! { ::actus::__internal::declares_expectation(#construction) },
1243 };
1244 quote! {
1245 .add_route(#path, ::std::sync::Arc::new(#construction))
1246 }
1247 });
1248
1249 quote! {
1250 pub async fn init(#(#init_params),*) -> ::actus::InitResult<::actus::Router> {
1251 #(#family_types)*
1252 #(#dep_lets)*
1253
1254 let router = ::actus::RouterBuilder::new()
1255 #(#route_calls)*
1256 .build();
1257
1258 ::std::result::Result::Ok(router)
1259 }
1260 }
1261}
1262
1263/// In a struct-literal controller construction, auto-clone simple references
1264/// to bound names so the same value can be threaded into multiple
1265/// controllers without each call site spelling `.clone()`.
1266///
1267/// Three cases get auto-cloned, all gated on the right-hand side being a
1268/// bare unqualified identifier (no path segments, no generic args, no
1269/// `qself`). The escape hatch in every case is the same: write any
1270/// non-ident expression — method call, function call, qualified path, an
1271/// already-`.clone()`d value — and it passes through unchanged.
1272///
1273/// * **Shorthand** — `Foo { db }` → `Foo { db: db.clone() }`.
1274/// * **Bare-ident explicit form** — `Foo { svc: store }` →
1275/// `Foo { svc: store.clone() }`.
1276/// * **Bare-ident rest spread** — `Foo { ..base }` → `Foo { ..(base).clone() }`.
1277/// Non-ident rest expressions (`..base.clone()`, `..self.template()`)
1278/// pass through verbatim — no double-cloning.
1279fn rewrite_construction(expr: &Expr) -> proc_macro2::TokenStream {
1280 let Expr::Struct(s) = expr else {
1281 return expr.to_token_stream();
1282 };
1283
1284 let path = &s.path;
1285 let mut inner = proc_macro2::TokenStream::new();
1286 let mut wrote_field = false;
1287
1288 for f in s.fields.iter() {
1289 if wrote_field {
1290 inner.extend(quote! { , });
1291 }
1292 wrote_field = true;
1293
1294 let member = &f.member;
1295 if f.colon_token.is_none() {
1296 // Shorthand: `name` → `name: name.clone()`
1297 inner.extend(quote! { #member: #member.clone() });
1298 } else if is_bare_ident(&f.expr) {
1299 // Explicit `target: source` where `source` is a simple ident:
1300 // treat like shorthand and auto-clone. Any non-ident expression
1301 // (method call, function call, qualified path, …) passes
1302 // through unchanged so callers retain a clean escape hatch.
1303 let value = &f.expr;
1304 inner.extend(quote! { #member: #value.clone() });
1305 } else {
1306 let value = &f.expr;
1307 inner.extend(quote! { #member: #value });
1308 }
1309 }
1310
1311 if let Some(rest) = &s.rest {
1312 if wrote_field {
1313 inner.extend(quote! { , });
1314 }
1315 if is_bare_ident(rest) {
1316 inner.extend(quote! { ..(#rest).clone() });
1317 } else {
1318 inner.extend(quote! { ..#rest });
1319 }
1320 }
1321
1322 quote! { #path { #inner } }
1323}
1324
1325/// Whether `expr` is a single, unqualified identifier path (no qself, no
1326/// leading `::`, exactly one segment, no generic args). The criterion the
1327/// auto-clone rule uses to decide that an explicit field assignment looks
1328/// "shorthand-like."
1329fn is_bare_ident(expr: &Expr) -> bool {
1330 let Expr::Path(p) = expr else { return false };
1331 p.qself.is_none()
1332 && p.path.leading_colon.is_none()
1333 && p.path.segments.len() == 1
1334 && p.path.segments[0].arguments.is_none()
1335}