1use proc_macro::TokenStream;
10use proc_macro2::Span;
11use quote::{format_ident, quote};
12
13const COMPLEXITY_CLASSES: &[&str] = &["1", "log n", "n", "n log n", "n^2"];
14const DEFAULT_SIZES: &[usize] = &[1024, 4096, 16384];
15
16#[derive(Default)]
17struct Attrs {
18 group: Option<String>,
19 setup: Option<syn::Path>,
20 setup_sized: Option<syn::Path>,
21 covers: String,
22 max_allocs: Option<u64>,
23 p99_ns: Option<u64>,
24 complexity: Option<String>,
25 sizes: Vec<usize>,
26}
27
28fn parse_attrs(attr: TokenStream, allowed: &[&str]) -> Result<Attrs, syn::Error> {
29 let mut out = Attrs::default();
30 let parser = syn::meta::parser(|meta| {
31 let key = meta
32 .path
33 .get_ident()
34 .map(ToString::to_string)
35 .unwrap_or_default();
36 if !allowed.contains(&key.as_str()) {
37 return Err(meta.error(format!(
38 "unsupported key `{key}`; expected one of: {}",
39 allowed.join(", ")
40 )));
41 }
42 match key.as_str() {
43 "group" => out.group = Some(meta.value()?.parse::<syn::LitStr>()?.value()),
44 "setup" => out.setup = Some(meta.value()?.parse()?),
45 "setup_sized" => out.setup_sized = Some(meta.value()?.parse()?),
46 "covers" => out.covers = meta.value()?.parse::<syn::LitStr>()?.value(),
47 "alloc" => out.max_allocs = Some(meta.value()?.parse::<syn::LitInt>()?.base10_parse()?),
48 "p99" => {
49 let lit = meta.value()?.parse::<syn::LitStr>()?;
50 out.p99_ns = Some(
51 parse_duration_ns(&lit.value()).map_err(|e| syn::Error::new(lit.span(), e))?,
52 );
53 }
54 "complexity" => {
55 let lit = meta.value()?.parse::<syn::LitStr>()?;
56 let class = lit.value();
57 if !COMPLEXITY_CLASSES.contains(&class.as_str()) {
58 return Err(syn::Error::new(
59 lit.span(),
60 format!("unknown complexity class; expected one of {COMPLEXITY_CLASSES:?}"),
61 ));
62 }
63 out.complexity = Some(class);
64 }
65 "sizes" => {
66 let content;
67 syn::parenthesized!(content in meta.input);
68 let list: syn::punctuated::Punctuated<syn::LitInt, syn::Token![,]> = content
69 .parse_terminated(<syn::LitInt as syn::parse::Parse>::parse, syn::Token![,])?;
70 out.sizes = list
71 .iter()
72 .map(syn::LitInt::base10_parse)
73 .collect::<Result<_, _>>()?;
74 }
75 _ => unreachable!(),
76 }
77 Ok(())
78 });
79 syn::parse::Parser::parse(parser, attr).map(|()| out)
80}
81
82fn parse_duration_ns(s: &str) -> Result<u64, String> {
84 let split = s
85 .find(|c: char| !c.is_ascii_digit())
86 .ok_or("missing unit (ns/us/ms/s)")?;
87 let (num, unit) = s.split_at(split);
88 let value: u64 = num.parse().map_err(|_| "invalid number".to_string())?;
89 let factor = match unit {
90 "ns" => 1,
91 "us" | "µs" => 1_000,
92 "ms" => 1_000_000,
93 "s" => 1_000_000_000,
94 other => return Err(format!("unknown duration unit {other:?}")),
95 };
96 Ok(value * factor)
97}
98
99#[proc_macro_attribute]
108pub fn measured(attr: TokenStream, item: TokenStream) -> TokenStream {
109 let attr_str = attr.to_string();
112 let attrs = match parse_attrs(attr, &["group", "alloc", "p99", "covers"]) {
113 Ok(a) => a,
114 Err(e) => return e.to_compile_error().into(),
115 };
116 let func = syn::parse_macro_input!(item as syn::ItemFn);
117 if !func.sig.inputs.is_empty() {
118 return syn::Error::new(
119 func.sig.ident.span(),
120 "#[measured] requires a zero-argument function; \
121 use #[soothfast::bench(setup = ...)] for functions with inputs",
122 )
123 .to_compile_error()
124 .into();
125 }
126 let name = &func.sig.ident;
127 let glue = if func.sig.asyncness.is_some() {
128 quote! { __b.iter_async(|| #name()); }
129 } else {
130 quote! { __b.iter(|| #name()); }
131 };
132 registration(&func, &attrs, &attr_str, glue, None)
133}
134
135#[proc_macro_attribute]
148pub fn bench(attr: TokenStream, item: TokenStream) -> TokenStream {
149 let attr_str = attr.to_string();
150 let mut attrs = match parse_attrs(
151 attr,
152 &[
153 "group",
154 "setup",
155 "setup_sized",
156 "covers",
157 "alloc",
158 "p99",
159 "complexity",
160 "sizes",
161 ],
162 ) {
163 Ok(a) => a,
164 Err(e) => return e.to_compile_error().into(),
165 };
166 let func = syn::parse_macro_input!(item as syn::ItemFn);
167 let name = &func.sig.ident;
168 let span = name.span();
169
170 if attrs.setup.is_some() && attrs.setup_sized.is_some() {
171 return err(span, "#[bench] takes `setup` or `setup_sized`, not both");
172 }
173 if attrs.complexity.is_some() && attrs.setup_sized.is_none() {
174 return err(span, "`complexity` needs `setup_sized = <fn(usize) -> T>`");
175 }
176 if attrs.sizes.is_empty() {
177 attrs.sizes = DEFAULT_SIZES.to_vec();
178 }
179 if attrs.complexity.is_some() && attrs.sizes.len() < 2 {
182 return err(
183 span,
184 "`complexity` needs `sizes(...)` with at least 2 sizes",
185 );
186 }
187
188 let iter_fn = if func.sig.asyncness.is_some() {
190 quote!(iter_async)
191 } else {
192 quote!(iter)
193 };
194 let arity = func.sig.inputs.len();
195 match (&attrs.setup, &attrs.setup_sized, arity) {
196 (Some(setup), None, 1) => {
197 let glue = quote! {
198 let __input = #setup();
199 __b.#iter_fn(|| #name(&__input));
200 };
201 registration(&func, &attrs, &attr_str, glue, None)
202 }
203 (None, Some(setup_sized), 1) => {
204 let largest = *attrs.sizes.iter().max().expect("sizes non-empty");
205 let glue_sized_ident = format_ident!("__soothfast_glue_sized_{}", name);
206 let sized = quote! {
207 #[doc(hidden)]
208 fn #glue_sized_ident(__b: &mut ::soothfast::__private::Bencher, __n: usize) {
209 let __input = #setup_sized(__n);
210 __b.#iter_fn(|| #name(&__input));
211 }
212 };
213 let glue = quote! { #glue_sized_ident(__b, #largest); };
214 registration(
215 &func,
216 &attrs,
217 &attr_str,
218 glue,
219 Some((sized, glue_sized_ident)),
220 )
221 }
222 (None, None, 0) => {
223 let glue = quote! { __b.#iter_fn(|| #name()); };
224 registration(&func, &attrs, &attr_str, glue, None)
225 }
226 (Some(_), None, n) => err(
227 span,
228 &format!("#[bench] with `setup` must take exactly one `&T`, found {n} args"),
229 ),
230 (None, Some(_), n) => err(
231 span,
232 &format!("#[bench] with `setup_sized` must take exactly one `&T`, found {n} args"),
233 ),
234 (None, None, _) => err(
235 span,
236 "#[bench] function has arguments but no `setup`; add `setup = <fn returning T>`",
237 ),
238 (Some(_), Some(_), _) => unreachable!("rejected above"),
239 }
240}
241
242fn err(span: Span, msg: &str) -> TokenStream {
243 syn::Error::new(span, msg).to_compile_error().into()
244}
245
246#[proc_macro_attribute]
275pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
276 let mut spec = String::new();
277 let mut operation = String::new();
278 let mut method = String::new();
279 let mut path = String::new();
280 let mut request: Option<String> = None;
281 let mut response: Option<String> = None;
282 let mut status: Option<u16> = None;
283 let mut params: Option<String> = None;
284 let mut path_params: Option<String> = None;
285 let parser = syn::meta::parser(|meta| {
286 let lit = |m: &syn::meta::ParseNestedMeta| -> syn::Result<String> {
287 Ok(m.value()?.parse::<syn::LitStr>()?.value())
288 };
289 if meta.path.is_ident("spec") {
290 spec = lit(&meta)?;
291 } else if meta.path.is_ident("operation") {
292 operation = lit(&meta)?;
293 } else if meta.path.is_ident("method") {
294 method = lit(&meta)?;
295 } else if meta.path.is_ident("path") {
296 path = lit(&meta)?;
297 } else if meta.path.is_ident("request") {
298 request = Some(lit(&meta)?);
299 } else if meta.path.is_ident("response") {
300 response = Some(lit(&meta)?);
301 } else if meta.path.is_ident("status") {
302 status = Some(
303 meta.value()?
304 .parse::<syn::LitInt>()?
305 .base10_parse::<u16>()?,
306 );
307 } else if meta.path.is_ident("params") {
308 params = Some(lit(&meta)?);
309 } else if meta.path.is_ident("path_params") {
310 path_params = Some(lit(&meta)?);
311 } else {
312 return Err(meta.error(
313 "expected `spec`, `operation`, `method`, `path`, `request`, \
314 `response`, `status`, `params`, or `path_params`",
315 ));
316 }
317 Ok(())
318 });
319 syn::parse_macro_input!(attr with parser);
320 let func = syn::parse_macro_input!(item as syn::ItemFn);
321 let name = &func.sig.ident;
322 if spec.is_empty() || operation.is_empty() {
323 return err(
324 name.span(),
325 "#[route] requires `spec = \"...\"` and `operation = \"...\"`",
326 );
327 }
328 let op_san: String = operation
330 .chars()
331 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
332 .collect();
333 let static_ident = format_ident!("__SOOTHFAST_ROUTE_{}_{}", name, op_san);
334 let opt_str = |v: &Option<String>| match v {
335 Some(s) => quote!(::core::option::Option::Some(#s)),
336 None => quote!(::core::option::Option::None),
337 };
338 let request_tok = opt_str(&request);
339 let response_tok = opt_str(&response);
340 let status_tok = match status {
341 Some(s) => quote!(::core::option::Option::Some(#s)),
342 None => quote!(::core::option::Option::None),
343 };
344 let params_tok = opt_str(¶ms);
345 let path_params_tok = opt_str(&path_params);
346 let summary = func.attrs.iter().find_map(|a| {
347 if !a.path().is_ident("doc") {
348 return None;
349 }
350 let syn::Meta::NameValue(nv) = &a.meta else {
351 return None;
352 };
353 let syn::Expr::Lit(syn::ExprLit {
354 lit: syn::Lit::Str(s),
355 ..
356 }) = &nv.value
357 else {
358 return None;
359 };
360 let line = s.value().trim().to_string();
361 (!line.is_empty()).then_some(line)
362 });
363 let summary_tok = opt_str(&summary);
364 quote! {
365 #func
366
367 #[::soothfast::__private::linkme::distributed_slice(::soothfast::__private::ROUTES)]
368 #[linkme(crate = ::soothfast::__private::linkme)]
369 #[allow(non_upper_case_globals)]
370 static #static_ident: ::soothfast::__private::RouteItem =
371 ::soothfast::__private::RouteItem::new(
372 concat!(module_path!(), "::", stringify!(#name)),
373 #spec,
374 #operation,
375 #method,
376 #path,
377 )
378 .with_shape(#request_tok, #response_tok, #status_tok)
379 .with_params(#params_tok)
380 .with_path_params(#path_params_tok)
381 .with_summary(#summary_tok);
382 }
383 .into()
384}
385
386#[proc_macro_attribute]
389pub fn fixture(attr: TokenStream, item: TokenStream) -> TokenStream {
390 if !attr.is_empty() {
391 return err(Span::call_site(), "#[fixture] takes no arguments");
392 }
393 let func = syn::parse_macro_input!(item as syn::ItemFn);
394 let name = &func.sig.ident;
395 let static_ident = format_ident!("__SOOTHFAST_FIXTURE_{}", name);
396 let normalized = fingerprint_input(&func, "");
397 quote! {
398 #func
399
400 #[::soothfast::__private::linkme::distributed_slice(::soothfast::__private::FIXTURES)]
401 #[linkme(crate = ::soothfast::__private::linkme)]
402 #[allow(non_upper_case_globals)]
403 static #static_ident: ::soothfast::__private::FixtureItem =
404 ::soothfast::__private::FixtureItem::new(
405 concat!(module_path!(), "::", stringify!(#name)),
406 ::soothfast::__private::fnv1a(#normalized.as_bytes()),
407 );
408 }
409 .into()
410}
411
412#[proc_macro_attribute]
422pub fn mock_seam(attr: TokenStream, item: TokenStream) -> TokenStream {
423 if !attr.is_empty() {
424 return err(Span::call_site(), "#[mock_seam] takes no arguments");
425 }
426 let func = syn::parse_macro_input!(item as syn::ItemFn);
427 let name = &func.sig.ident;
428 let span = name.span();
429 let call = match func.sig.inputs.len() {
430 0 => quote! { #name() },
431 1 => quote! { #name(__arg) },
432 n => {
433 return err(
434 span,
435 &format!("#[mock_seam] function must take zero args or one `&str`, found {n} args"),
436 );
437 }
438 };
439 let static_ident = format_ident!("__SOOTHFAST_MOCK_{}", name);
440 let glue_ident = format_ident!("__soothfast_mock_glue_{}", name);
441 let normalized = fingerprint_input(&func, "");
442 quote! {
443 #func
444
445 #[doc(hidden)]
446 fn #glue_ident(__arg: &str) -> ::std::boxed::Box<dyn ::soothfast::__private::MockSeam> {
447 ::std::boxed::Box::new(#call)
448 }
449
450 #[::soothfast::__private::linkme::distributed_slice(::soothfast::__private::MOCKS)]
451 #[linkme(crate = ::soothfast::__private::linkme)]
452 #[allow(non_upper_case_globals)]
453 static #static_ident: ::soothfast::__private::MockSeamItem =
454 ::soothfast::__private::MockSeamItem::new(
455 concat!(module_path!(), "::", stringify!(#name)),
456 ::soothfast::__private::fnv1a(#normalized.as_bytes()),
457 #glue_ident,
458 );
459 }
460 .into()
461}
462
463fn opt_u64(v: Option<u64>) -> proc_macro2::TokenStream {
464 match v {
465 Some(x) => quote!(::core::option::Option::Some(#x)),
466 None => quote!(::core::option::Option::None),
467 }
468}
469
470fn opt_str(v: &Option<String>) -> proc_macro2::TokenStream {
471 match v {
472 Some(s) => quote!(::core::option::Option::Some(#s)),
473 None => quote!(::core::option::Option::None),
474 }
475}
476
477fn fingerprint_input(func: &syn::ItemFn, attr_str: &str) -> String {
481 let mut f = func.clone();
482 f.attrs.retain(|a| !a.path().is_ident("doc"));
483 format!("{attr_str}\u{1}{}", quote!(#f))
484}
485
486fn registration(
488 func: &syn::ItemFn,
489 attrs: &Attrs,
490 attr_str: &str,
491 iter_call: proc_macro2::TokenStream,
492 sized: Option<(proc_macro2::TokenStream, syn::Ident)>,
493) -> TokenStream {
494 let name = &func.sig.ident;
495 let group = attrs.group.clone().unwrap_or_else(|| "default".into());
496 let covers = &attrs.covers;
497 let normalized = fingerprint_input(func, attr_str);
498 let glue_ident = format_ident!("__soothfast_glue_{}", name);
499 let static_ident = format_ident!("__SOOTHFAST_MEASURED_{}", name);
500
501 let mut entry = quote! {
502 ::soothfast::__private::MeasuredItem::new(
503 env!("CARGO_PKG_NAME"),
504 module_path!(),
505 stringify!(#name),
506 #group,
507 ::soothfast::__private::fnv1a(#normalized.as_bytes()),
508 #covers,
509 #glue_ident,
510 )
511 };
512 let setup_name = attrs
515 .setup
516 .as_ref()
517 .or(attrs.setup_sized.as_ref())
518 .and_then(|p| p.segments.last())
519 .map(|s| s.ident.to_string());
520 if let Some(s) = setup_name {
521 entry = quote! { #entry.with_setup(#s) };
522 }
523 if attrs.max_allocs.is_some() || attrs.p99_ns.is_some() || attrs.complexity.is_some() {
524 let (a, p, c) = (
525 opt_u64(attrs.max_allocs),
526 opt_u64(attrs.p99_ns),
527 opt_str(&attrs.complexity),
528 );
529 entry = quote! {
530 #entry.with_assertions(::soothfast::__private::Assertions::new(#a, #p, #c))
531 };
532 }
533 if func.sig.asyncness.is_some() {
534 entry = quote! { #entry.with_async() };
535 }
536 let sized_defs = match &sized {
537 Some((defs, sized_ident)) => {
538 let sizes = &attrs.sizes;
539 entry = quote! { #entry.with_sweep(&[#(#sizes),*], #sized_ident) };
540 defs.clone()
541 }
542 None => quote!(),
543 };
544
545 quote! {
546 #func
547
548 #sized_defs
549
550 #[doc(hidden)]
551 fn #glue_ident(__b: &mut ::soothfast::__private::Bencher) {
552 #iter_call
553 }
554
555 #[::soothfast::__private::linkme::distributed_slice(::soothfast::__private::MEASURED)]
556 #[linkme(crate = ::soothfast::__private::linkme)]
557 #[allow(non_upper_case_globals)]
558 static #static_ident: ::soothfast::__private::MeasuredItem = #entry;
559 }
560 .into()
561}