Skip to main content

apidoc_macros/
lib.rs

1//! Attribute macros for apidoc: title / desc / method / url / param / query /
2//! returned (M1), tag / group / author / header / route_param /
3//! response_status / success / error / not_debug / md / sort / ref (M3),
4//! and app (M6b).
5//!
6//! Each macro keeps the annotated function unchanged and emits a statically
7//! registered `DocFragmentEntry` on the distributed slice `apidoc::DOC_FRAGMENTS`,
8//! so documentation fragments are collected at zero runtime cost.
9
10mod args;
11
12use args::{HeaderArgs, ParamArgs, SuccessArgs};
13use proc_macro::TokenStream;
14use proc_macro2::Span;
15use quote::{format_ident, quote};
16use std::sync::atomic::{AtomicU32, Ordering};
17use syn::parse::ParseStream;
18use syn::punctuated::Punctuated;
19use syn::{parse_macro_input, Ident, Item, ItemFn, LitInt, LitStr, Token};
20
21const HTTP_METHODS: &[&str] = &["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
22
23// Monotonic sequence number per annotation, assigned in expansion order (which
24// follows source order in practice). Restores declaration order at collect
25// time, because linkme's linker-section iteration order is not source order.
26static SEQ: AtomicU32 = AtomicU32::new(0);
27
28#[proc_macro_attribute]
29pub fn title(args: TokenStream, item: TokenStream) -> TokenStream {
30    simple_fragment("title", args, item)
31}
32
33#[proc_macro_attribute]
34pub fn desc(args: TokenStream, item: TokenStream) -> TokenStream {
35    simple_fragment("desc", args, item)
36}
37
38#[proc_macro_attribute]
39pub fn method(args: TokenStream, item: TokenStream) -> TokenStream {
40    simple_fragment("method", args, item)
41}
42
43#[proc_macro_attribute]
44pub fn url(args: TokenStream, item: TokenStream) -> TokenStream {
45    simple_fragment("url", args, item)
46}
47
48#[proc_macro_attribute]
49pub fn param(args: TokenStream, item: TokenStream) -> TokenStream {
50    param_fragment("param", args, item)
51}
52
53#[proc_macro_attribute]
54pub fn query(args: TokenStream, item: TokenStream) -> TokenStream {
55    param_fragment("query", args, item)
56}
57
58#[proc_macro_attribute]
59pub fn returned(args: TokenStream, item: TokenStream) -> TokenStream {
60    param_fragment("returned", args, item)
61}
62
63// —— M3: 13 new annotations ——
64
65#[proc_macro_attribute]
66pub fn tag(args: TokenStream, item: TokenStream) -> TokenStream {
67    litstr_fragment("tag", args, item)
68}
69
70#[proc_macro_attribute]
71pub fn group(args: TokenStream, item: TokenStream) -> TokenStream {
72    simple_fragment("group", args, item)
73}
74
75#[proc_macro_attribute]
76pub fn author(args: TokenStream, item: TokenStream) -> TokenStream {
77    simple_fragment("author", args, item)
78}
79
80#[proc_macro_attribute]
81pub fn header(args: TokenStream, item: TokenStream) -> TokenStream {
82    let args = parse_macro_input!(args as HeaderArgs);
83    let item_fn = match parse_item_fn("header", item) {
84        Ok(f) => f,
85        Err(e) => return e.to_compile_error().into(),
86    };
87    if args.name.as_deref().is_none_or(str::is_empty) {
88        return syn::Error::new(
89            Span::call_site(),
90            "apidoc::header requires a non-empty `name`",
91        )
92        .to_compile_error()
93        .into();
94    }
95    let name = args.name.as_deref().unwrap();
96    let desc = opt_lit(&args.desc);
97    let frag = quote! { apidoc::DocFragment::Header(apidoc::DocHeader { name: #name, desc: #desc }) };
98    emit_many("header", item_fn, vec![frag])
99}
100
101#[proc_macro_attribute]
102pub fn route_param(args: TokenStream, item: TokenStream) -> TokenStream {
103    param_fragment("route_param", args, item)
104}
105
106#[proc_macro_attribute]
107pub fn response_status(args: TokenStream, item: TokenStream) -> TokenStream {
108    litstr_fragment("response_status", args, item)
109}
110
111#[proc_macro_attribute]
112pub fn success(args: TokenStream, item: TokenStream) -> TokenStream {
113    example_fragment("success", args, item)
114}
115
116#[proc_macro_attribute]
117pub fn error(args: TokenStream, item: TokenStream) -> TokenStream {
118    example_fragment("error", args, item)
119}
120
121#[proc_macro_attribute]
122pub fn not_debug(args: TokenStream, item: TokenStream) -> TokenStream {
123    if !args.is_empty() {
124        return syn::Error::new(
125            Span::call_site(),
126            "apidoc::not_debug takes no arguments",
127        )
128        .to_compile_error()
129        .into();
130    }
131    let item_fn = match parse_item_fn("not_debug", item) {
132        Ok(f) => f,
133        Err(e) => return e.to_compile_error().into(),
134    };
135    emit_many("not_debug", item_fn, vec![quote! { apidoc::DocFragment::NotDebug }])
136}
137
138#[proc_macro_attribute]
139pub fn md(args: TokenStream, item: TokenStream) -> TokenStream {
140    simple_fragment("md", args, item)
141}
142
143#[proc_macro_attribute]
144pub fn sort(args: TokenStream, item: TokenStream) -> TokenStream {
145    // Integer literal, optionally negative: #[apidoc::sort(-1)].
146    let parser = |input: ParseStream| -> syn::Result<i32> {
147        let neg = input.peek(Token![-]);
148        if neg {
149            input.parse::<Token![-]>()?;
150        }
151        let lit: LitInt = input.parse()?;
152        let n = lit.base10_parse::<i32>()?;
153        Ok(if neg { -n } else { n })
154    };
155    let n = match syn::parse::Parser::parse2(parser, args.into()) {
156        Ok(n) => n,
157        Err(e) => return e.to_compile_error().into(),
158    };
159    let item_fn = match parse_item_fn("sort", item) {
160        Ok(f) => f,
161        Err(e) => return e.to_compile_error().into(),
162    };
163    let frag = quote! { apidoc::DocFragment::Sort(#n) };
164    emit_many("sort", item_fn, vec![frag])
165}
166
167#[proc_macro_attribute]
168pub fn r#ref(args: TokenStream, item: TokenStream) -> TokenStream {
169    simple_fragment("ref", args, item)
170}
171
172/// M6b: 将接口挂到指定应用/版本 key 下(key 须在 ApidocConfig.apps 中配置)。
173#[proc_macro_attribute]
174pub fn app(args: TokenStream, item: TokenStream) -> TokenStream {
175    simple_fragment("app", args, item)
176}
177
178/// title / desc / method / url / group / author / md / ref: a single string
179/// literal plus validation.
180fn simple_fragment(kind: &str, args: TokenStream, item: TokenStream) -> TokenStream {
181    let lit = parse_macro_input!(args as LitStr);
182    let value = lit.value();
183    let err = match kind {
184        "url" if !value.starts_with('/') => {
185            Some(syn::Error::new(lit.span(), "apidoc::url must start with '/'"))
186        }
187        "method" if !HTTP_METHODS.contains(&value.as_str()) => Some(syn::Error::new(
188            lit.span(),
189            format!(
190                "apidoc::method must be one of {:?}, got `{}`",
191                HTTP_METHODS, value
192            ),
193        )),
194        "group" | "author" | "ref" | "app" if value.trim().is_empty() => Some(syn::Error::new(
195            lit.span(),
196            format!("apidoc::{kind} must not be empty"),
197        )),
198        _ => None,
199    };
200    if let Some(err) = err {
201        return err.to_compile_error().into();
202    }
203    let item_fn = match parse_item_fn(kind, item) {
204        Ok(f) => f,
205        Err(e) => return e.to_compile_error().into(),
206    };
207    let variant = variant_ident(kind);
208    let frag = quote! { apidoc::DocFragment::#variant(#lit) };
209    emit_many(kind, item_fn, vec![frag])
210}
211
212/// param / query / returned: keyword-style arguments, e.g.
213/// `#[apidoc::param(name = "id", ty = "int", required, desc = "ID", mock = "1")]`
214/// with nested `children = [{ name = "x", ... }, ...]`.
215fn param_fragment(kind: &str, args: TokenStream, item: TokenStream) -> TokenStream {
216    let args = parse_macro_input!(args as ParamArgs);
217    let item_fn = match parse_item_fn(kind, item) {
218        Ok(f) => f,
219        Err(e) => return e.to_compile_error().into(),
220    };
221    if args.name.as_deref().is_none_or(str::is_empty) {
222        return syn::Error::new(
223            Span::call_site(),
224            format!("apidoc::{kind} requires a non-empty `name`"),
225        )
226        .to_compile_error()
227        .into();
228    }
229    let variant = variant_ident(kind);
230    let doc_param = doc_param_expr(&args);
231    let frag = quote! { apidoc::DocFragment::#variant(#doc_param) };
232    emit_many(kind, item_fn, vec![frag])
233}
234
235/// DocFragment enum variant name for a macro kind: "title" -> Title,
236/// "response_status" -> ResponseStatus, "not_debug" -> NotDebug.
237fn variant_ident(kind: &str) -> Ident {
238    let upper = match kind {
239        "response_status" => "ResponseStatus".to_string(),
240        "route_param" => "RouteParam".to_string(),
241        "not_debug" => "NotDebug".to_string(),
242        _ => {
243            let mut chars = kind.chars();
244            let first = chars.next().unwrap().to_ascii_uppercase();
245            format!("{first}{}", chars.as_str())
246        }
247    };
248    Ident::new(&upper, Span::call_site())
249}
250
251fn parse_item_fn(kind: &str, item: TokenStream) -> syn::Result<ItemFn> {
252    let item = syn::parse::<Item>(item)?;
253    match item {
254        Item::Fn(f) => Ok(f),
255        other => Err(syn::Error::new_spanned(
256            other,
257            format!("apidoc::{kind} can only be applied to a function"),
258        )),
259    }
260}
261
262fn doc_param_expr(args: &ParamArgs) -> proc_macro2::TokenStream {
263    let name = args.name.as_deref().unwrap_or("");
264    let ty = args.ty.as_deref().unwrap_or("string");
265    let required = args.required;
266    let default = opt_lit(&args.default);
267    let desc = opt_lit(&args.desc);
268    let mock = opt_lit(&args.mock);
269    let children = args.children.iter().map(doc_param_expr);
270    quote! {
271        apidoc::DocParam {
272            name: #name,
273            ty: #ty,
274            required: #required,
275            default: #default,
276            desc: #desc,
277            mock: #mock,
278            children: &[#(#children),*],
279        }
280    }
281}
282
283fn opt_lit(value: &Option<String>) -> proc_macro2::TokenStream {
284    match value {
285        Some(s) => quote! { Some(#s) },
286        None => quote! { None },
287    }
288}
289
290/// Emits the original function plus one static fragment registration per
291/// fragment. Variadic annotations (tag / response_status) expand to several
292/// statics from a single attribute.
293fn emit_many(kind: &str, item_fn: ItemFn, frags: Vec<proc_macro2::TokenStream>) -> TokenStream {
294    let fn_ident = item_fn.sig.ident.clone();
295    let kind_upper = kind.to_uppercase();
296    let mut out = quote! { #item_fn };
297    for frag in frags {
298        // seq makes the static name unique even for repeated same-name params
299        // on one function, which would otherwise collide into a misleading
300        // E0428; it also restores declaration order at collect time.
301        let seq = SEQ.fetch_add(1, Ordering::Relaxed);
302        let static_ident = format_ident!("__APIDOC_{kind_upper}_{fn_ident}_{seq}");
303        out.extend(quote! {
304            #[apidoc::distributed_slice(apidoc::DOC_FRAGMENTS)]
305            static #static_ident: apidoc::DocFragmentEntry = apidoc::DocFragmentEntry {
306                id: concat!(module_path!(), "::", stringify!(#fn_ident)),
307                seq: #seq,
308                frag: #frag,
309            };
310        });
311    }
312    out.into()
313}
314
315/// tag / response_status: one or more string literals, each validated and
316/// expanded into its own fragment registration.
317fn litstr_fragment(kind: &str, args: TokenStream, item: TokenStream) -> TokenStream {
318    let lits = match litstr_list(args) {
319        Ok(l) => l,
320        Err(e) => return e.to_compile_error().into(),
321    };
322    let item_fn = match parse_item_fn(kind, item) {
323        Ok(f) => f,
324        Err(e) => return e.to_compile_error().into(),
325    };
326    for lit in &lits {
327        if let Some(err) = validate_lit(kind, lit) {
328            return err.to_compile_error().into();
329        }
330    }
331    let variant = variant_ident(kind);
332    let frags = lits.iter().map(|lit| quote! { apidoc::DocFragment::#variant(#lit) }).collect();
333    emit_many(kind, item_fn, frags)
334}
335
336/// Parses a comma-separated list of one or more string literals.
337fn litstr_list(args: TokenStream) -> syn::Result<Vec<LitStr>> {
338    let list = syn::parse::Parser::parse2(
339        Punctuated::<LitStr, Token![,]>::parse_terminated,
340        args.into(),
341    )?;
342    if list.is_empty() {
343        Err(syn::Error::new(
344            Span::call_site(),
345            "expected at least one string literal",
346        ))
347    } else {
348        Ok(list.into_iter().collect())
349    }
350}
351
352fn validate_lit(kind: &str, lit: &LitStr) -> Option<syn::Error> {
353    let v = lit.value();
354    match kind {
355        "response_status"
356            if v.parse::<u16>().map_or(true, |n| !(100..=599).contains(&n)) =>
357        {
358            Some(syn::Error::new(
359                lit.span(),
360                format!(
361                    "apidoc::response_status must be a numeric HTTP status code 100-599, got `{v}`"
362                ),
363            ))
364        }
365        "tag" if v.trim().is_empty() => {
366            Some(syn::Error::new(lit.span(), "apidoc::tag must not be empty"))
367        }
368        _ => None,
369    }
370}
371
372/// success / error: `code` and `example` are both required.
373fn example_fragment(kind: &str, args: TokenStream, item: TokenStream) -> TokenStream {
374    let args = parse_macro_input!(args as SuccessArgs);
375    let item_fn = match parse_item_fn(kind, item) {
376        Ok(f) => f,
377        Err(e) => return e.to_compile_error().into(),
378    };
379    let Some(code) = args.code.as_ref() else {
380        return syn::Error::new(
381            Span::call_site(),
382            format!("apidoc::{kind} requires `code`"),
383        )
384        .to_compile_error()
385        .into();
386    };
387    if code.value().parse::<u16>().map_or(true, |n| !(100..=599).contains(&n)) {
388        return syn::Error::new(
389            code.span(),
390            format!(
391                "apidoc::{kind} code must be a numeric HTTP status code 100-599, got `{}`",
392                code.value()
393            ),
394        )
395        .to_compile_error()
396        .into();
397    }
398    let Some(example) = args.example.as_ref() else {
399        return syn::Error::new(
400            Span::call_site(),
401            format!("apidoc::{kind} requires `example`"),
402        )
403        .to_compile_error()
404        .into();
405    };
406    let variant = variant_ident(kind);
407    let frag = quote! {
408        apidoc::DocFragment::#variant(apidoc::DocExample { code: #code, example: #example })
409    };
410    emit_many(kind, item_fn, vec![frag])
411}
412