Skip to main content

tako_rs_macros/
lib.rs

1//! Proc macros for the tako-rs framework.
2//!
3//! Provides [`route`], an attribute macro placed directly above an async
4//! handler function. Given an HTTP method and a path with `{name: Type}`
5//! placeholders, it generates a sibling `pub struct` whose fields exactly
6//! mirror the placeholders, plus:
7//!
8//! - `pub const METHOD: tako::Method` and `pub const PATH: &'static str`
9//! - an `impl TypedParamsStruct` that pulls each field from the request's
10//!   `PathParams` extension and parses it via [`core::str::FromStr`]
11//!
12//! The struct name is auto-derived from the handler function's name
13//! (`snake_case` → `PascalCase` + `Params`). For example, `get_user` produces
14//! `GetUserParams`. Override the default with `name = "..."` if you need a
15//! different identifier.
16//!
17//! Method-specific shortcuts ([`get`], [`post`], [`put`], [`delete`],
18//! [`patch`]) take only the path and an optional `name = "..."`.
19//!
20//! Usage:
21//!
22//! ```ignore
23//! use tako::{get, route};
24//! use tako::extractors::typed_params::TypedParams;
25//! use tako::responder::Responder;
26//!
27//! #[route(GET, "/users/{id: u64}/posts/{post_id: u64}")]
28//! async fn get_user(TypedParams(p): TypedParams<GetUserParams>) -> impl Responder {
29//!     format!("user {} post {}", p.id, p.post_id)
30//! }
31//!
32//! #[get("/health")]
33//! async fn health() -> impl Responder { "ok" }
34//!
35//! // …in build_router:
36//! // router.route(GetUserParams::METHOD, GetUserParams::PATH, get_user);
37//! // router.route(HealthParams::METHOD, HealthParams::PATH, health);
38//! ```
39//!
40//! The macro must be attached to a free async fn at module scope — Rust
41//! scopes structs declared inside fn bodies to that fn, so the generated
42//! type wouldn't be reachable from the handler signature otherwise.
43
44use proc_macro::TokenStream;
45use proc_macro2::Span;
46use proc_macro2::TokenStream as TokenStream2;
47use quote::format_ident;
48use quote::quote;
49use syn::Ident;
50use syn::ItemFn;
51use syn::LitStr;
52use syn::Token;
53use syn::Type;
54use syn::parse::Parse;
55use syn::parse::ParseStream;
56use syn::parse_macro_input;
57use syn::parse_str;
58
59struct RouteArgs {
60  method: Ident,
61  path: LitStr,
62  name_override: Option<Ident>,
63}
64
65impl Parse for RouteArgs {
66  fn parse(input: ParseStream) -> syn::Result<Self> {
67    let method: Ident = input.parse()?;
68    input.parse::<Token![,]>()?;
69    let path: LitStr = input.parse()?;
70    let name_override = parse_optional_name(input)?;
71    Ok(Self {
72      method,
73      path,
74      name_override,
75    })
76  }
77}
78
79struct ShortcutArgs {
80  path: LitStr,
81  name_override: Option<Ident>,
82}
83
84impl Parse for ShortcutArgs {
85  fn parse(input: ParseStream) -> syn::Result<Self> {
86    let path: LitStr = input.parse()?;
87    let name_override = parse_optional_name(input)?;
88    Ok(Self {
89      path,
90      name_override,
91    })
92  }
93}
94
95/// After the path literal there can optionally be `, name = "Foo"`. Returns
96/// `Ok(None)` if the comma/keyword is absent, `Err` only on a malformed key.
97fn parse_optional_name(input: ParseStream) -> syn::Result<Option<Ident>> {
98  if input.is_empty() {
99    return Ok(None);
100  }
101  input.parse::<Token![,]>()?;
102  if input.is_empty() {
103    return Ok(None);
104  }
105  let key: Ident = input.parse()?;
106  if key != "name" {
107    return Err(syn::Error::new(key.span(), "expected `name = \"...\"`"));
108  }
109  input.parse::<Token![=]>()?;
110  let lit: LitStr = input.parse()?;
111  let ident: Ident = parse_str(&lit.value())
112    .map_err(|e| syn::Error::new(lit.span(), format!("invalid struct name: {e}")))?;
113  Ok(Some(ident))
114}
115
116struct PathParam {
117  name: Ident,
118  ty: Type,
119}
120
121/// Parses path placeholders. Two syntaxes are accepted:
122/// - typed: `{id: u64}` — emits a field on the generated `*Params` struct
123/// - untyped: `{id}` — matchit/axum-style; passes through untouched and does
124///   not contribute to the `*Params` struct
125///
126/// Returns the matchit-friendly stripped path (every placeholder reduced to
127/// `{name}`) plus the list of typed `(name, type)` pairs only.
128fn parse_path(path: &str, span: Span) -> syn::Result<(String, Vec<PathParam>)> {
129  // Route paths are ASCII per RFC 3986 (`reserved` + `unreserved` are both
130  // ASCII subsets). Reject anything else up front rather than mojibake the
131  // byte stream into the stripped output: previously a multi-byte UTF-8
132  // char like `é` (`0xC3 0xA9`) was pushed as two distinct `char` values,
133  // both Latin-1 codepoints, breaking exact-path matching against the
134  // matchit-compiled route.
135  if !path.is_ascii() {
136    return Err(syn::Error::new(
137      span,
138      "route path must be ASCII (RFC 3986); percent-encode any non-ASCII characters",
139    ));
140  }
141  let mut stripped = String::with_capacity(path.len());
142  let mut typed = Vec::new();
143  let bytes = path.as_bytes();
144  let mut i = 0;
145  while i < bytes.len() {
146    let c = bytes[i];
147    if c == b'}' {
148      // Stray `}` without a preceding `{` is a path-syntax mistake. Reject
149      // explicitly so the error surfaces at macro-expansion time rather
150      // than as a downstream matchit mismatch.
151      return Err(syn::Error::new(
152        span,
153        "unexpected '}' in path (no matching '{')",
154      ));
155    }
156    if c != b'{' {
157      stripped.push(c as char);
158      i += 1;
159      continue;
160    }
161    let close = (i + 1..bytes.len())
162      .find(|&j| bytes[j] == b'}')
163      .ok_or_else(|| syn::Error::new(span, "unclosed '{' in path"))?;
164    let inner = &path[i + 1..close];
165    if let Some((name_str, ty_str)) = inner.split_once(':') {
166      let name: Ident = parse_str(name_str.trim()).map_err(|e| {
167        syn::Error::new(
168          span,
169          format!("invalid placeholder name '{}': {e}", name_str.trim()),
170        )
171      })?;
172      let ty: Type = parse_str(ty_str.trim()).map_err(|e| {
173        syn::Error::new(
174          span,
175          format!("invalid placeholder type '{}': {e}", ty_str.trim()),
176        )
177      })?;
178      stripped.push('{');
179      stripped.push_str(&name.to_string());
180      stripped.push('}');
181      typed.push(PathParam { name, ty });
182    } else {
183      let name: Ident = parse_str(inner.trim()).map_err(|e| {
184        syn::Error::new(
185          span,
186          format!("invalid placeholder name '{}': {e}", inner.trim()),
187        )
188      })?;
189      stripped.push('{');
190      stripped.push_str(&name.to_string());
191      stripped.push('}');
192    }
193    i = close + 1;
194  }
195  Ok((stripped, typed))
196}
197
198/// `snake_case` → `PascalCase`. `get_user` → `GetUser`. ASCII only, which is
199/// fine for Rust identifiers.
200fn pascal_case(s: &str) -> String {
201  let mut out = String::with_capacity(s.len());
202  let mut next_upper = true;
203  for ch in s.chars() {
204    if ch == '_' {
205      next_upper = true;
206    } else if next_upper {
207      out.extend(ch.to_uppercase());
208      next_upper = false;
209    } else {
210      out.push(ch);
211    }
212  }
213  out
214}
215
216/// Shared expansion: given a method ident, a path literal, an optional struct
217/// name override, and the handler fn, produce the generated tokens.
218///
219/// Only emits the `*Params` struct when the path contains at least one typed
220/// placeholder (`{id: u64}`). Pure-static or untyped-only paths skip the
221/// struct entirely and just register the route.
222fn expand_route(
223  method: Ident,
224  path: LitStr,
225  name_override: Option<Ident>,
226  func: ItemFn,
227) -> TokenStream {
228  let span = path.span();
229  let path_str = path.value();
230  let (stripped, params) = match parse_path(&path_str, span) {
231    Ok(v) => v,
232    Err(e) => return e.to_compile_error().into(),
233  };
234
235  let fn_name = &func.sig.ident;
236  // Append a short fingerprint of (method + path) so two handlers that
237  // happen to share the same function identifier — common when several
238  // modules each define an `fn handler` — generate distinct linkme
239  // registrars. Without the suffix the second module's static silently
240  // overwrote the first at link time.
241  let registrar_suffix = {
242    let key = format!("{method}_{path_str}");
243    let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a 64-bit offset basis
244    for byte in key.as_bytes() {
245      hash ^= u64::from(*byte);
246      hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
247    }
248    format!("{hash:016X}")
249  };
250  let registrar_ident = format_ident!(
251    "__TAKO_REGISTER_{}_{}",
252    fn_name.to_string().to_uppercase(),
253    registrar_suffix,
254    span = fn_name.span()
255  );
256
257  // No typed placeholders.
258  if params.is_empty() {
259    // Explicit `name = "..."` keeps emitting a unit marker struct so callers
260    // can still reference `Name::METHOD` / `Name::PATH`. Without an override
261    // we skip the struct entirely.
262    if let Some(struct_name) = name_override {
263      let expanded: TokenStream2 = quote! {
264        pub struct #struct_name;
265
266        impl #struct_name {
267          pub const METHOD: ::tako::Method = ::tako::Method::#method;
268          pub const PATH: &'static str = #stripped;
269        }
270
271        #[::tako::__private::linkme::distributed_slice(::tako::router::TAKO_ROUTES)]
272        #[linkme(crate = ::tako::__private::linkme)]
273        static #registrar_ident: fn(&mut ::tako::router::Router) = |__router| {
274          __router.route(#struct_name::METHOD, #struct_name::PATH, #fn_name);
275        };
276
277        #func
278      };
279      return expanded.into();
280    }
281
282    let expanded: TokenStream2 = quote! {
283      #[::tako::__private::linkme::distributed_slice(::tako::router::TAKO_ROUTES)]
284      #[linkme(crate = ::tako::__private::linkme)]
285      static #registrar_ident: fn(&mut ::tako::router::Router) = |__router| {
286        __router.route(::tako::Method::#method, #stripped, #fn_name);
287      };
288
289      #func
290    };
291    return expanded.into();
292  }
293
294  let struct_name = name_override.unwrap_or_else(|| {
295    format_ident!(
296      "{}Params",
297      pascal_case(&fn_name.to_string()),
298      span = fn_name.span()
299    )
300  });
301
302  let field_idents: Vec<&Ident> = params.iter().map(|p| &p.name).collect();
303  let field_names_str: Vec<String> = params.iter().map(|p| p.name.to_string()).collect();
304  let field_types: Vec<&Type> = params.iter().map(|p| &p.ty).collect();
305
306  let expanded: TokenStream2 = quote! {
307    pub struct #struct_name {
308      #(pub #field_idents: #field_types,)*
309    }
310
311    impl #struct_name {
312      pub const METHOD: ::tako::Method = ::tako::Method::#method;
313      pub const PATH: &'static str = #stripped;
314    }
315
316    impl ::tako::extractors::typed_params::TypedParamsStruct for #struct_name {
317      fn from_path_params(
318        __pp: &::tako::extractors::params::PathParams,
319      ) -> ::core::result::Result<Self, ::tako::extractors::typed_params::TypedParamsError> {
320        ::core::result::Result::Ok(Self {
321          #(
322            #field_idents: {
323              let __raw = __pp
324                .0
325                .iter()
326                .find(|(__k, _)| __k.as_str() == #field_names_str)
327                .map(|(_, __v)| __v.as_str())
328                .ok_or(::tako::extractors::typed_params::TypedParamsError::MissingField(
329                  #field_names_str,
330                ))?;
331              <#field_types as ::core::str::FromStr>::from_str(__raw).map_err(|__e| {
332                ::tako::extractors::typed_params::TypedParamsError::Parse(
333                  #field_names_str,
334                  __e.to_string(),
335                )
336              })?
337            },
338          )*
339        })
340      }
341    }
342
343    #[::tako::__private::linkme::distributed_slice(::tako::router::TAKO_ROUTES)]
344    #[linkme(crate = ::tako::__private::linkme)]
345    static #registrar_ident: fn(&mut ::tako::router::Router) = |__router| {
346      __router.route(#struct_name::METHOD, #struct_name::PATH, #fn_name);
347    };
348
349    #func
350  };
351
352  expanded.into()
353}
354
355/// Common driver for the method shortcuts (`#[get]`, `#[post]`, ...).
356fn shortcut(method_name: &'static str, attr: TokenStream, item: TokenStream) -> TokenStream {
357  let ShortcutArgs {
358    path,
359    name_override,
360  } = parse_macro_input!(attr as ShortcutArgs);
361  let func = parse_macro_input!(item as ItemFn);
362  let method = Ident::new(method_name, Span::call_site());
363  expand_route(method, path, name_override, func)
364}
365
366#[proc_macro_attribute]
367pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
368  let RouteArgs {
369    method,
370    path,
371    name_override,
372  } = parse_macro_input!(attr as RouteArgs);
373  let func = parse_macro_input!(item as ItemFn);
374  expand_route(method, path, name_override, func)
375}
376
377/// `#[get("/path", [name = "Foo"])]` — shorthand for `#[route(GET, ...)]`.
378#[proc_macro_attribute]
379pub fn get(attr: TokenStream, item: TokenStream) -> TokenStream {
380  shortcut("GET", attr, item)
381}
382
383/// `#[post("/path", [name = "Foo"])]` — shorthand for `#[route(POST, ...)]`.
384#[proc_macro_attribute]
385pub fn post(attr: TokenStream, item: TokenStream) -> TokenStream {
386  shortcut("POST", attr, item)
387}
388
389/// `#[put("/path", [name = "Foo"])]` — shorthand for `#[route(PUT, ...)]`.
390#[proc_macro_attribute]
391pub fn put(attr: TokenStream, item: TokenStream) -> TokenStream {
392  shortcut("PUT", attr, item)
393}
394
395/// `#[delete("/path", [name = "Foo"])]` — shorthand for `#[route(DELETE, ...)]`.
396#[proc_macro_attribute]
397pub fn delete(attr: TokenStream, item: TokenStream) -> TokenStream {
398  shortcut("DELETE", attr, item)
399}
400
401/// `#[patch("/path", [name = "Foo"])]` — shorthand for `#[route(PATCH, ...)]`.
402#[proc_macro_attribute]
403pub fn patch(attr: TokenStream, item: TokenStream) -> TokenStream {
404  shortcut("PATCH", attr, item)
405}