Skip to main content

soothfast_macros/
lib.rs

1//! Proc-macros for soothfast.
2//!
3//! `#[measured]`, `#[bench]`, `#[fixture]`, and `#[mock_seam]` register the
4//! annotated function into the soothfast registry (see `soothfast-registry`)
5//! without altering the function itself. `#[measured]`/`#[bench]` also
6//! accept checked performance claims: `alloc = N`, `p99 = "1ms"`, and
7//! (bench only) `complexity = "n log n"` with `setup_sized`/`sizes(...)`.
8
9use 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
88/// Parse `"250ns" | "10us" | "1ms" | "2s"` into nanoseconds.
89fn 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
105/// Parse `"8%"` into a percentage.
106fn 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/// Register a zero-argument function as a directly measured item.
119///
120/// ```ignore
121/// #[soothfast::measured(group = "checksums", alloc = 0, p99 = "100us")]
122/// pub fn lcg_checksum() -> u64 { ... }
123/// ```
124///
125/// Functions with parameters need `#[soothfast::bench(setup = ...)]` instead.
126#[proc_macro_attribute]
127pub fn measured(attr: TokenStream, item: TokenStream) -> TokenStream {
128    // The raw attr tokens join the fingerprint: threshold/claim edits are
129    // meaningful changes even though they sit outside the fn tokens.
130    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/// Register a bench harness function, optionally fed by a setup fn.
155///
156/// ```ignore
157/// #[soothfast::bench(group = "sort", setup_sized = values_n, sizes(1024, 4096, 16384),
158///                  complexity = "n log n", alloc = 2, covers = "mylib::sorted")]
159/// fn bench_sorted(input: &[f64]) { soothfast::keep(mylib::sorted(input)); }
160/// ```
161///
162/// `setup = f` feeds `f() -> T` as `&T`; `setup_sized = f` feeds `f(n) -> T`
163/// and enables `sizes(...)`/`complexity` sweeps (the plain measurement uses
164/// the largest size). Setup always runs outside the measured region.
165/// Route constant inputs through `soothfast::keep` or LLVM const-folds them.
166#[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    // A sweep needs two points to have a slope; catch it here rather than
200    // let the runner panic mid-measurement.
201    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    // Async bench fns drive the counting executor via iter_async.
209    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/// Declare the spec operation a handler implements — the code-side half of
267/// `cargo soothfast spec check`. Stackable for handlers serving several
268/// surfaces (REST + GraphQL + MCP).
269///
270/// Spec generation infers the request and response shapes from the handler's
271/// own signature; `request`, `response`, `status`, `params` and `path_params`
272/// override that for what inference provably cannot see — erased returns, and
273/// detached markers whose empty signature says nothing at all.
274///
275/// ```ignore
276/// #[soothfast::route(spec = "specs/openapi.yaml", operation = "getItem",
277///                  method = "GET", path = "/items/{id}")]
278/// pub fn get_item(id: u64) -> String { ... }
279///
280/// // `impl IntoResponse` erases the type; name it explicitly.
281/// #[soothfast::route(spec = "specs/openapi.yaml", operation = "createItem",
282///                  method = "POST", path = "/items",
283///                  response = "Item", status = 201)]
284/// pub async fn create_item(body: Json<NewItem>) -> impl IntoResponse { ... }
285///
286/// // A detached marker states its whole contract. `path_params` types the
287/// // `{placeholder}`s that would otherwise be bare strings.
288/// #[soothfast::route(spec = "specs/openapi.yaml", operation = "listBySector",
289///                  method = "GET", path = "/sectors/{sector}",
290///                  params = "SectorQuery", path_params = "sector: Sector",
291///                  response = "[Item]")]
292/// fn route_list_by_sector() {}
293/// ```
294#[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    // Operation in the ident so stacked #[route] attrs don't collide.
349    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(&params);
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/// Mark a deterministic input-builder. Registers metadata only; the function
407/// is referenced by name from `#[bench(setup = ...)]` / `setup_sized`.
408#[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/// Mark a mock-backend setup fn, resolved at runtime by name via
433/// `soothfast::mock::activate` — referenced from a markdown `mock=NAME` /
434/// `mock=NAME(ARG)` tag, which is plain text rather than a `syn::Path`, so
435/// (unlike `#[fixture]`, wired at compile time through a macro attribute
436/// argument) this can't be called from a literal call site the macro
437/// controls; the registry stores a name-keyed fn pointer instead.
438///
439/// The annotated fn takes zero args or one `&str` (the tag's `(ARG)`, `""`
440/// when omitted) and returns any `T: soothfast::registry::MockSeam`.
441#[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
497/// Normalized token string for fingerprinting: `TokenStream::to_string()`
498/// canonicalizes spacing (formatting-independent), and doc-comment attrs are
499/// stripped first so a typo fix in prose never flags the code as changed.
500fn 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
506/// Shared expansion: original fn + glue fn(s) + registry entry.
507fn 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    // Record the setup fn name so the runner can fold the fixture's
533    // fingerprint into this item's (input changes are workload changes).
534    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}