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 tolerance_pct: Option<f64>,
27}
28
29fn parse_attrs(attr: TokenStream, allowed: &[&str]) -> Result<Attrs, syn::Error> {
30 let mut out = Attrs::default();
31 let parser = syn::meta::parser(|meta| {
32 let key = meta
33 .path
34 .get_ident()
35 .map(ToString::to_string)
36 .unwrap_or_default();
37 if !allowed.contains(&key.as_str()) {
38 return Err(meta.error(format!(
39 "unsupported key `{key}`; expected one of: {}",
40 allowed.join(", ")
41 )));
42 }
43 match key.as_str() {
44 "group" => out.group = Some(meta.value()?.parse::<syn::LitStr>()?.value()),
45 "setup" => out.setup = Some(meta.value()?.parse()?),
46 "setup_sized" => out.setup_sized = Some(meta.value()?.parse()?),
47 "covers" => out.covers = meta.value()?.parse::<syn::LitStr>()?.value(),
48 "alloc" => out.max_allocs = Some(meta.value()?.parse::<syn::LitInt>()?.base10_parse()?),
49 "tolerance" => {
50 let lit = meta.value()?.parse::<syn::LitStr>()?;
51 out.tolerance_pct =
52 Some(parse_percent(&lit.value()).map_err(|e| syn::Error::new(lit.span(), e))?);
53 }
54 "p99" => {
55 let lit = meta.value()?.parse::<syn::LitStr>()?;
56 out.p99_ns = Some(
57 parse_duration_ns(&lit.value()).map_err(|e| syn::Error::new(lit.span(), e))?,
58 );
59 }
60 "complexity" => {
61 let lit = meta.value()?.parse::<syn::LitStr>()?;
62 let class = lit.value();
63 if !COMPLEXITY_CLASSES.contains(&class.as_str()) {
64 return Err(syn::Error::new(
65 lit.span(),
66 format!("unknown complexity class; expected one of {COMPLEXITY_CLASSES:?}"),
67 ));
68 }
69 out.complexity = Some(class);
70 }
71 "sizes" => {
72 let content;
73 syn::parenthesized!(content in meta.input);
74 let list: syn::punctuated::Punctuated<syn::LitInt, syn::Token![,]> = content
75 .parse_terminated(<syn::LitInt as syn::parse::Parse>::parse, syn::Token![,])?;
76 out.sizes = list
77 .iter()
78 .map(syn::LitInt::base10_parse)
79 .collect::<Result<_, _>>()?;
80 }
81 _ => unreachable!(),
82 }
83 Ok(())
84 });
85 syn::parse::Parser::parse(parser, attr).map(|()| out)
86}
87
88fn parse_duration_ns(s: &str) -> Result<u64, String> {
90 let split = s
91 .find(|c: char| !c.is_ascii_digit())
92 .ok_or("missing unit (ns/us/ms/s)")?;
93 let (num, unit) = s.split_at(split);
94 let value: u64 = num.parse().map_err(|_| "invalid number".to_string())?;
95 let factor = match unit {
96 "ns" => 1,
97 "us" | "µs" => 1_000,
98 "ms" => 1_000_000,
99 "s" => 1_000_000_000,
100 other => return Err(format!("unknown duration unit {other:?}")),
101 };
102 Ok(value * factor)
103}
104
105fn parse_percent(s: &str) -> Result<f64, String> {
107 let num = s.strip_suffix('%').ok_or("missing `%`")?;
108 let value: f64 = num
109 .trim()
110 .parse()
111 .map_err(|_| "invalid number".to_string())?;
112 if value <= 0.0 {
113 return Err("tolerance must be positive".into());
114 }
115 Ok(value)
116}
117
118#[proc_macro_attribute]
127pub fn measured(attr: TokenStream, item: TokenStream) -> TokenStream {
128 let attr_str = attr.to_string();
131 let attrs = match parse_attrs(attr, &["group", "alloc", "p99", "covers"]) {
132 Ok(a) => a,
133 Err(e) => return e.to_compile_error().into(),
134 };
135 let func = syn::parse_macro_input!(item as syn::ItemFn);
136 if !func.sig.inputs.is_empty() {
137 return syn::Error::new(
138 func.sig.ident.span(),
139 "#[measured] requires a zero-argument function; \
140 use #[soothfast::bench(setup = ...)] for functions with inputs",
141 )
142 .to_compile_error()
143 .into();
144 }
145 let name = &func.sig.ident;
146 let glue = if func.sig.asyncness.is_some() {
147 quote! { __b.iter_async(|| #name()); }
148 } else {
149 quote! { __b.iter(|| #name()); }
150 };
151 registration(&func, &attrs, &attr_str, glue, None)
152}
153
154#[proc_macro_attribute]
167pub fn bench(attr: TokenStream, item: TokenStream) -> TokenStream {
168 let attr_str = attr.to_string();
169 let mut attrs = match parse_attrs(
170 attr,
171 &[
172 "group",
173 "setup",
174 "setup_sized",
175 "covers",
176 "alloc",
177 "p99",
178 "complexity",
179 "sizes",
180 "tolerance",
181 ],
182 ) {
183 Ok(a) => a,
184 Err(e) => return e.to_compile_error().into(),
185 };
186 let func = syn::parse_macro_input!(item as syn::ItemFn);
187 let name = &func.sig.ident;
188 let span = name.span();
189
190 if attrs.setup.is_some() && attrs.setup_sized.is_some() {
191 return err(span, "#[bench] takes `setup` or `setup_sized`, not both");
192 }
193 if attrs.complexity.is_some() && attrs.setup_sized.is_none() {
194 return err(span, "`complexity` needs `setup_sized = <fn(usize) -> T>`");
195 }
196 if attrs.sizes.is_empty() {
197 attrs.sizes = DEFAULT_SIZES.to_vec();
198 }
199 if attrs.complexity.is_some() && attrs.sizes.len() < 2 {
202 return err(
203 span,
204 "`complexity` needs `sizes(...)` with at least 2 sizes",
205 );
206 }
207
208 let iter_fn = if func.sig.asyncness.is_some() {
210 quote!(iter_async)
211 } else {
212 quote!(iter)
213 };
214 let arity = func.sig.inputs.len();
215 match (&attrs.setup, &attrs.setup_sized, arity) {
216 (Some(setup), None, 1) => {
217 let glue = quote! {
218 let __input = #setup();
219 __b.#iter_fn(|| #name(&__input));
220 };
221 registration(&func, &attrs, &attr_str, glue, None)
222 }
223 (None, Some(setup_sized), 1) => {
224 let largest = *attrs.sizes.iter().max().expect("sizes non-empty");
225 let glue_sized_ident = format_ident!("__soothfast_glue_sized_{}", name);
226 let sized = quote! {
227 #[doc(hidden)]
228 fn #glue_sized_ident(__b: &mut ::soothfast::__private::Bencher, __n: usize) {
229 let __input = #setup_sized(__n);
230 __b.#iter_fn(|| #name(&__input));
231 }
232 };
233 let glue = quote! { #glue_sized_ident(__b, #largest); };
234 registration(
235 &func,
236 &attrs,
237 &attr_str,
238 glue,
239 Some((sized, glue_sized_ident)),
240 )
241 }
242 (None, None, 0) => {
243 let glue = quote! { __b.#iter_fn(|| #name()); };
244 registration(&func, &attrs, &attr_str, glue, None)
245 }
246 (Some(_), None, n) => err(
247 span,
248 &format!("#[bench] with `setup` must take exactly one `&T`, found {n} args"),
249 ),
250 (None, Some(_), n) => err(
251 span,
252 &format!("#[bench] with `setup_sized` must take exactly one `&T`, found {n} args"),
253 ),
254 (None, None, _) => err(
255 span,
256 "#[bench] function has arguments but no `setup`; add `setup = <fn returning T>`",
257 ),
258 (Some(_), Some(_), _) => unreachable!("rejected above"),
259 }
260}
261
262fn err(span: Span, msg: &str) -> TokenStream {
263 syn::Error::new(span, msg).to_compile_error().into()
264}
265
266#[proc_macro_attribute]
295pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
296 let mut spec = String::new();
297 let mut operation = String::new();
298 let mut method = String::new();
299 let mut path = String::new();
300 let mut request: Option<String> = None;
301 let mut response: Option<String> = None;
302 let mut status: Option<u16> = None;
303 let mut params: Option<String> = None;
304 let mut path_params: Option<String> = None;
305 let parser = syn::meta::parser(|meta| {
306 let lit = |m: &syn::meta::ParseNestedMeta| -> syn::Result<String> {
307 Ok(m.value()?.parse::<syn::LitStr>()?.value())
308 };
309 if meta.path.is_ident("spec") {
310 spec = lit(&meta)?;
311 } else if meta.path.is_ident("operation") {
312 operation = lit(&meta)?;
313 } else if meta.path.is_ident("method") {
314 method = lit(&meta)?;
315 } else if meta.path.is_ident("path") {
316 path = lit(&meta)?;
317 } else if meta.path.is_ident("request") {
318 request = Some(lit(&meta)?);
319 } else if meta.path.is_ident("response") {
320 response = Some(lit(&meta)?);
321 } else if meta.path.is_ident("status") {
322 status = Some(
323 meta.value()?
324 .parse::<syn::LitInt>()?
325 .base10_parse::<u16>()?,
326 );
327 } else if meta.path.is_ident("params") {
328 params = Some(lit(&meta)?);
329 } else if meta.path.is_ident("path_params") {
330 path_params = Some(lit(&meta)?);
331 } else {
332 return Err(meta.error(
333 "expected `spec`, `operation`, `method`, `path`, `request`, \
334 `response`, `status`, `params`, or `path_params`",
335 ));
336 }
337 Ok(())
338 });
339 syn::parse_macro_input!(attr with parser);
340 let func = syn::parse_macro_input!(item as syn::ItemFn);
341 let name = &func.sig.ident;
342 if spec.is_empty() || operation.is_empty() {
343 return err(
344 name.span(),
345 "#[route] requires `spec = \"...\"` and `operation = \"...\"`",
346 );
347 }
348 let op_san: String = operation
350 .chars()
351 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
352 .collect();
353 let static_ident = format_ident!("__SOOTHFAST_ROUTE_{}_{}", name, op_san);
354 let opt_str = |v: &Option<String>| match v {
355 Some(s) => quote!(::core::option::Option::Some(#s)),
356 None => quote!(::core::option::Option::None),
357 };
358 let request_tok = opt_str(&request);
359 let response_tok = opt_str(&response);
360 let status_tok = match status {
361 Some(s) => quote!(::core::option::Option::Some(#s)),
362 None => quote!(::core::option::Option::None),
363 };
364 let params_tok = opt_str(¶ms);
365 let path_params_tok = opt_str(&path_params);
366 let summary = func.attrs.iter().find_map(|a| {
367 if !a.path().is_ident("doc") {
368 return None;
369 }
370 let syn::Meta::NameValue(nv) = &a.meta else {
371 return None;
372 };
373 let syn::Expr::Lit(syn::ExprLit {
374 lit: syn::Lit::Str(s),
375 ..
376 }) = &nv.value
377 else {
378 return None;
379 };
380 let line = s.value().trim().to_string();
381 (!line.is_empty()).then_some(line)
382 });
383 let summary_tok = opt_str(&summary);
384 quote! {
385 #func
386
387 #[::soothfast::__private::linkme::distributed_slice(::soothfast::__private::ROUTES)]
388 #[linkme(crate = ::soothfast::__private::linkme)]
389 #[allow(non_upper_case_globals)]
390 static #static_ident: ::soothfast::__private::RouteItem =
391 ::soothfast::__private::RouteItem::new(
392 concat!(module_path!(), "::", stringify!(#name)),
393 #spec,
394 #operation,
395 #method,
396 #path,
397 )
398 .with_shape(#request_tok, #response_tok, #status_tok)
399 .with_params(#params_tok)
400 .with_path_params(#path_params_tok)
401 .with_summary(#summary_tok);
402 }
403 .into()
404}
405
406#[proc_macro_attribute]
409pub fn fixture(attr: TokenStream, item: TokenStream) -> TokenStream {
410 if !attr.is_empty() {
411 return err(Span::call_site(), "#[fixture] takes no arguments");
412 }
413 let func = syn::parse_macro_input!(item as syn::ItemFn);
414 let name = &func.sig.ident;
415 let static_ident = format_ident!("__SOOTHFAST_FIXTURE_{}", name);
416 let normalized = fingerprint_input(&func, "");
417 quote! {
418 #func
419
420 #[::soothfast::__private::linkme::distributed_slice(::soothfast::__private::FIXTURES)]
421 #[linkme(crate = ::soothfast::__private::linkme)]
422 #[allow(non_upper_case_globals)]
423 static #static_ident: ::soothfast::__private::FixtureItem =
424 ::soothfast::__private::FixtureItem::new(
425 concat!(module_path!(), "::", stringify!(#name)),
426 ::soothfast::__private::fnv1a(#normalized.as_bytes()),
427 );
428 }
429 .into()
430}
431
432#[proc_macro_attribute]
442pub fn mock_seam(attr: TokenStream, item: TokenStream) -> TokenStream {
443 if !attr.is_empty() {
444 return err(Span::call_site(), "#[mock_seam] takes no arguments");
445 }
446 let func = syn::parse_macro_input!(item as syn::ItemFn);
447 let name = &func.sig.ident;
448 let span = name.span();
449 let call = match func.sig.inputs.len() {
450 0 => quote! { #name() },
451 1 => quote! { #name(__arg) },
452 n => {
453 return err(
454 span,
455 &format!("#[mock_seam] function must take zero args or one `&str`, found {n} args"),
456 );
457 }
458 };
459 let static_ident = format_ident!("__SOOTHFAST_MOCK_{}", name);
460 let glue_ident = format_ident!("__soothfast_mock_glue_{}", name);
461 let normalized = fingerprint_input(&func, "");
462 quote! {
463 #func
464
465 #[doc(hidden)]
466 fn #glue_ident(__arg: &str) -> ::std::boxed::Box<dyn ::soothfast::__private::MockSeam> {
467 ::std::boxed::Box::new(#call)
468 }
469
470 #[::soothfast::__private::linkme::distributed_slice(::soothfast::__private::MOCKS)]
471 #[linkme(crate = ::soothfast::__private::linkme)]
472 #[allow(non_upper_case_globals)]
473 static #static_ident: ::soothfast::__private::MockSeamItem =
474 ::soothfast::__private::MockSeamItem::new(
475 concat!(module_path!(), "::", stringify!(#name)),
476 ::soothfast::__private::fnv1a(#normalized.as_bytes()),
477 #glue_ident,
478 );
479 }
480 .into()
481}
482
483fn opt_u64(v: Option<u64>) -> proc_macro2::TokenStream {
484 match v {
485 Some(x) => quote!(::core::option::Option::Some(#x)),
486 None => quote!(::core::option::Option::None),
487 }
488}
489
490fn opt_str(v: &Option<String>) -> proc_macro2::TokenStream {
491 match v {
492 Some(s) => quote!(::core::option::Option::Some(#s)),
493 None => quote!(::core::option::Option::None),
494 }
495}
496
497fn fingerprint_input(func: &syn::ItemFn, attr_str: &str) -> String {
501 let mut f = func.clone();
502 f.attrs.retain(|a| !a.path().is_ident("doc"));
503 format!("{attr_str}\u{1}{}", quote!(#f))
504}
505
506fn registration(
508 func: &syn::ItemFn,
509 attrs: &Attrs,
510 attr_str: &str,
511 iter_call: proc_macro2::TokenStream,
512 sized: Option<(proc_macro2::TokenStream, syn::Ident)>,
513) -> TokenStream {
514 let name = &func.sig.ident;
515 let group = attrs.group.clone().unwrap_or_else(|| "default".into());
516 let covers = &attrs.covers;
517 let normalized = fingerprint_input(func, attr_str);
518 let glue_ident = format_ident!("__soothfast_glue_{}", name);
519 let static_ident = format_ident!("__SOOTHFAST_MEASURED_{}", name);
520
521 let mut entry = quote! {
522 ::soothfast::__private::MeasuredItem::new(
523 env!("CARGO_PKG_NAME"),
524 module_path!(),
525 stringify!(#name),
526 #group,
527 ::soothfast::__private::fnv1a(#normalized.as_bytes()),
528 #covers,
529 #glue_ident,
530 )
531 };
532 let setup_name = attrs
535 .setup
536 .as_ref()
537 .or(attrs.setup_sized.as_ref())
538 .and_then(|p| p.segments.last())
539 .map(|s| s.ident.to_string());
540 if let Some(s) = setup_name {
541 entry = quote! { #entry.with_setup(#s) };
542 }
543 if attrs.max_allocs.is_some() || attrs.p99_ns.is_some() || attrs.complexity.is_some() {
544 let (a, p, c) = (
545 opt_u64(attrs.max_allocs),
546 opt_u64(attrs.p99_ns),
547 opt_str(&attrs.complexity),
548 );
549 entry = quote! {
550 #entry.with_assertions(::soothfast::__private::Assertions::new(#a, #p, #c))
551 };
552 }
553 if let Some(t) = attrs.tolerance_pct {
554 entry = quote! { #entry.with_tolerance(#t) };
555 }
556 if func.sig.asyncness.is_some() {
557 entry = quote! { #entry.with_async() };
558 }
559 let sized_defs = match &sized {
560 Some((defs, sized_ident)) => {
561 let sizes = &attrs.sizes;
562 entry = quote! { #entry.with_sweep(&[#(#sizes),*], #sized_ident) };
563 defs.clone()
564 }
565 None => quote!(),
566 };
567
568 quote! {
569 #func
570
571 #sized_defs
572
573 #[doc(hidden)]
574 fn #glue_ident(__b: &mut ::soothfast::__private::Bencher) {
575 #iter_call
576 }
577
578 #[::soothfast::__private::linkme::distributed_slice(::soothfast::__private::MEASURED)]
579 #[linkme(crate = ::soothfast::__private::linkme)]
580 #[allow(non_upper_case_globals)]
581 static #static_ident: ::soothfast::__private::MeasuredItem = #entry;
582 }
583 .into()
584}