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}
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
82/// Parse `"250ns" | "10us" | "1ms" | "2s"` into nanoseconds.
83fn 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/// Register a zero-argument function as a directly measured item.
100///
101/// ```ignore
102/// #[soothfast::measured(group = "checksums", alloc = 0, p99 = "100us")]
103/// pub fn lcg_checksum() -> u64 { ... }
104/// ```
105///
106/// Functions with parameters need `#[soothfast::bench(setup = ...)]` instead.
107#[proc_macro_attribute]
108pub fn measured(attr: TokenStream, item: TokenStream) -> TokenStream {
109    // The raw attr tokens join the fingerprint: threshold/claim edits are
110    // meaningful changes even though they sit outside the fn tokens.
111    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/// Register a bench harness function, optionally fed by a setup fn.
136///
137/// ```ignore
138/// #[soothfast::bench(group = "sort", setup_sized = values_n, sizes(1024, 4096, 16384),
139///                  complexity = "n log n", alloc = 2, covers = "mylib::sorted")]
140/// fn bench_sorted(input: &[f64]) { soothfast::keep(mylib::sorted(input)); }
141/// ```
142///
143/// `setup = f` feeds `f() -> T` as `&T`; `setup_sized = f` feeds `f(n) -> T`
144/// and enables `sizes(...)`/`complexity` sweeps (the plain measurement uses
145/// the largest size). Setup always runs outside the measured region.
146/// Route constant inputs through `soothfast::keep` or LLVM const-folds them.
147#[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    // A sweep needs two points to have a slope; catch it here rather than
180    // let the runner panic mid-measurement.
181    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    // Async bench fns drive the counting executor via iter_async.
189    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/// Declare the spec operation a handler implements — the code-side half of
247/// `cargo soothfast spec check`. Stackable for handlers serving several
248/// surfaces (REST + GraphQL + MCP).
249///
250/// Spec generation infers the request and response shapes from the handler's
251/// own signature; `request`, `response`, `status`, `params` and `path_params`
252/// override that for what inference provably cannot see — erased returns, and
253/// detached markers whose empty signature says nothing at all.
254///
255/// ```ignore
256/// #[soothfast::route(spec = "specs/openapi.yaml", operation = "getItem",
257///                  method = "GET", path = "/items/{id}")]
258/// pub fn get_item(id: u64) -> String { ... }
259///
260/// // `impl IntoResponse` erases the type; name it explicitly.
261/// #[soothfast::route(spec = "specs/openapi.yaml", operation = "createItem",
262///                  method = "POST", path = "/items",
263///                  response = "Item", status = 201)]
264/// pub async fn create_item(body: Json<NewItem>) -> impl IntoResponse { ... }
265///
266/// // A detached marker states its whole contract. `path_params` types the
267/// // `{placeholder}`s that would otherwise be bare strings.
268/// #[soothfast::route(spec = "specs/openapi.yaml", operation = "listBySector",
269///                  method = "GET", path = "/sectors/{sector}",
270///                  params = "SectorQuery", path_params = "sector: Sector",
271///                  response = "[Item]")]
272/// fn route_list_by_sector() {}
273/// ```
274#[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    // Operation in the ident so stacked #[route] attrs don't collide.
329    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(&params);
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/// Mark a deterministic input-builder. Registers metadata only; the function
387/// is referenced by name from `#[bench(setup = ...)]` / `setup_sized`.
388#[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/// Mark a mock-backend setup fn, resolved at runtime by name via
413/// `soothfast::mock::activate` — referenced from a markdown `mock=NAME` /
414/// `mock=NAME(ARG)` tag, which is plain text rather than a `syn::Path`, so
415/// (unlike `#[fixture]`, wired at compile time through a macro attribute
416/// argument) this can't be called from a literal call site the macro
417/// controls; the registry stores a name-keyed fn pointer instead.
418///
419/// The annotated fn takes zero args or one `&str` (the tag's `(ARG)`, `""`
420/// when omitted) and returns any `T: soothfast::registry::MockSeam`.
421#[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
477/// Normalized token string for fingerprinting: `TokenStream::to_string()`
478/// canonicalizes spacing (formatting-independent), and doc-comment attrs are
479/// stripped first so a typo fix in prose never flags the code as changed.
480fn 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
486/// Shared expansion: original fn + glue fn(s) + registry entry.
487fn 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    // Record the setup fn name so the runner can fold the fixture's
513    // fingerprint into this item's (input changes are workload changes).
514    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}