Skip to main content

api_parity_rs_macros/
lib.rs

1//! Attribute macros for api-parity-rs (port-side plugin).
2//!
3//! Domain-agnostic: the `path` value can name a PySpark API, a REST
4//! endpoint, a TypeScript type — anything `api-parity` can left-join on.
5//!
6//! Two forms:
7//!
8//! - `#[parity_impl(...)]` on an `impl` block. Its `path` (if any) acts as
9//!   a *prefix* for relative child paths; if `path` AND `status` are both
10//!   present, an entry is also registered for the impl itself, with the
11//!   implementation set to the type name (e.g. `SparkSession`).
12//! - `#[parity(...)]` on a method or free `fn`. Inside an `#[parity_impl]`,
13//!   a leading `.` in `path` (e.g. `.builder`) is replaced at compile time
14//!   with `parent_path + child` (e.g. `pyspark.sql.session.SparkSession.builder`).
15//!
16//! Recognized arguments:
17//! - `path = "..."` (required to emit an entry).
18//! - `status = Implemented | Partial | Unimplemented` (required to emit an entry).
19//! - `since = "..."`, `comment = "..."`, `issue = 42` (optional).
20//! - `status = Unimplemented` requires a `comment` — the whole point of a
21//!   stub is that the comment explains *why* it's unimplemented.
22
23use proc_macro::TokenStream;
24use proc_macro2::TokenStream as TS2;
25use quote::quote;
26use syn::{
27    parse_macro_input, spanned::Spanned, Attribute, Error, ImplItem, ItemFn,
28    ItemImpl, LitInt, LitStr,
29};
30
31/// Parsed `#[parity(...)]` arguments. Used by both macros.
32#[derive(Default)]
33struct ParityArgs {
34    path: Option<LitStr>,
35    status: Option<syn::Ident>,
36    since: Option<LitStr>,
37    comment: Option<LitStr>,
38    issue: Option<LitInt>,
39}
40
41fn parse_args(attr: &Attribute) -> Result<ParityArgs, Error> {
42    let mut args = ParityArgs::default();
43    // `parse_nested_meta` walks `key = value` pairs and calls the closure
44    // once per pair. Returning `Err` propagates as a `syn::Error` with
45    // the right span pointing at the offending token.
46    attr.parse_nested_meta(|meta| {
47        if meta.path.is_ident("path") {
48            args.path = Some(meta.value()?.parse()?);
49        } else if meta.path.is_ident("status") {
50            args.status = Some(meta.value()?.parse()?);
51        } else if meta.path.is_ident("since") {
52            args.since = Some(meta.value()?.parse()?);
53        } else if meta.path.is_ident("comment") {
54            args.comment = Some(meta.value()?.parse()?);
55        } else if meta.path.is_ident("issue") {
56            args.issue = Some(meta.value()?.parse()?);
57        } else {
58            // Unknown key: reject loudly so typos don't silently no-op.
59            return Err(meta.error(format!(
60                "parity: unknown argument `{}` (expected one of: path, status, since, comment, issue)",
61                meta.path.get_ident().map(|i| i.to_string()).unwrap_or_default(),
62            )));
63        }
64        Ok(())
65    })?;
66    Ok(args)
67}
68
69/// Attribute on an `impl` block. Walks the block's methods, strips any
70/// `#[parity(...)]` attributes, and emits one `inventory::submit!` per
71/// stripped attribute. The implementation path is `Self::fn_name`, so
72/// the type prefix is auto-derived (the user doesn't have to repeat it).
73///
74/// Output token stream layout:
75/// ```text
76/// <original impl block, with #[parity] attrs removed from methods>
77/// <one inventory::submit! { ParityEntry { ... } } per stripped attr>
78/// ```
79/// The submits sit at module scope next to the impl, which is where
80/// `inventory::submit!` expects them.
81#[proc_macro_attribute]
82pub fn parity_impl(args: TokenStream, input: TokenStream) -> TokenStream {
83    // Parse the annotated item as an `impl` block. `parse_macro_input!`
84    // bails with a compile error if the input isn't an impl.
85    let mut item: ItemImpl = parse_macro_input!(input as ItemImpl);
86
87    // `self_ty` is the type after `impl`, e.g. `SparkSession` or
88    // `Foo<'a, T>`. Stringify it (stripping the spaces the token-printer
89    // adds) to use as the impl path of every annotated method.
90    let self_ty = &item.self_ty;
91    let self_ty_str = quote!(#self_ty).to_string().replace(' ', "");
92
93    // Parse the impl-level args once. Wrap in a fake attribute so we can
94    // reuse `parse_args` (which expects a `syn::Attribute`).
95    let args2: TS2 = args.into();
96    let parent_attr: Attribute = syn::parse_quote!(#[parity(#args2)]);
97    let parent_args = match parse_args(&parent_attr) {
98        Ok(a) => a,
99        Err(e) => return e.into_compile_error().into(),
100    };
101    let parent_path_str = parent_args.path.as_ref().map(|r| r.value());
102
103    // Accumulator for all submit! calls we emit alongside the impl.
104    let mut submits = TS2::new();
105
106    // If the impl declares both path and status, register the class
107    // itself. The implementation here is the type name (e.g. `SparkSession`),
108    // mirroring how methods get a `Self::fn_name` impl path.
109    if parent_args.path.is_some() && parent_args.status.is_some() {
110        let lit = LitStr::new(&self_ty_str, proc_macro2::Span::call_site());
111        let tokens = build_submit(&parent_args, parent_attr.span(), quote!(#lit), None)
112            .unwrap_or_else(Error::into_compile_error);
113        submits.extend(tokens);
114    }
115
116    for impl_item in &mut item.items {
117        // Only methods are interesting; skip consts, types, etc.
118        if let ImplItem::Fn(method) = impl_item {
119            // Take all `#[parity(...)]` attrs off the method (so they
120            // don't reach rustc as unknown attrs) and remember them.
121            // `Vec::retain` lets us partition in place.
122            let mut child_attrs = Vec::new();
123            method.attrs.retain(|attr| {
124                if attr.path().is_ident("parity") {
125                    child_attrs.push(attr.clone());
126                    false // drop from the method
127                } else {
128                    true // keep other attrs (e.g. #[inline])
129                }
130            });
131
132            // For each removed `#[parity(...)]` build a submit! call.
133            // Multiple per method is allowed but unusual.
134            for attr in child_attrs {
135                let fn_name = method.sig.ident.to_string();
136                let impl_path = format!("{}::{}", self_ty_str, fn_name);
137                let lit = LitStr::new(&impl_path, proc_macro2::Span::call_site());
138                let tokens = match parse_args(&attr) {
139                    Ok(child) => build_submit(
140                        &child,
141                        attr.span(),
142                        quote!(#lit),
143                        parent_path_str.as_deref(),
144                    )
145                    .unwrap_or_else(Error::into_compile_error),
146                    Err(e) => e.into_compile_error(),
147                };
148                submits.extend(tokens);
149            }
150        }
151    }
152
153    // Emit the (now de-attributed) impl followed by the submits.
154    let out = quote! {
155        #item
156        #submits
157    };
158    out.into()
159}
160
161/// Attribute on a free `fn`. Used when there's no enclosing impl block to
162/// provide a type prefix; the implementation path becomes
163/// `module_path!()::fn_name` (resolved at compile time of the *user*
164/// crate, since `module_path!()` expands in place).
165#[proc_macro_attribute]
166pub fn parity(args: TokenStream, input: TokenStream) -> TokenStream {
167    let item: ItemFn = parse_macro_input!(input as ItemFn);
168    let fn_name = item.sig.ident.to_string();
169    // We can't compute the path here because we don't know the user's
170    // module path — `concat!` defers it until the user crate compiles.
171    let impl_path_expr = quote! { concat!(module_path!(), "::", #fn_name) };
172
173    // Reuse the same arg parser as the impl form by wrapping the bare
174    // arg TokenStream into a fake attribute.
175    let args2: TS2 = args.into();
176    let attr: Attribute = syn::parse_quote!(#[parity(#args2)]);
177    let submit = match parse_args(&attr) {
178        Ok(parsed) => build_submit(&parsed, attr.span(), impl_path_expr, None)
179            .unwrap_or_else(Error::into_compile_error),
180        Err(e) => e.into_compile_error(),
181    };
182
183    // Original fn passes through unchanged; the submit sits beside it.
184    let out = quote! {
185        #item
186        #submit
187    };
188    out.into()
189}
190
191/// Emit `inventory::submit! { ParityEntry { ... } }`.
192///
193/// `parent_path` is the enclosing impl's `path` value (if any). A child
194/// `path` starting with `.` is rewritten at expansion time as
195/// `parent_path + child`, producing a single `&'static str` literal in
196/// the generated code (so the registered entry holds one string, not a
197/// runtime `concat!`).
198///
199/// Returns a `syn::Error` instead of panicking so callers can convert it
200/// into a `compile_error!` diagnostic with span info.
201fn build_submit(
202    args: &ParityArgs,
203    span: proc_macro2::Span,
204    impl_path_expr: TS2,
205    parent_path: Option<&str>,
206) -> Result<TS2, Error> {
207    let path_lit = args.path.as_ref().ok_or_else(|| {
208        Error::new(span, "parity: missing required `path = \"...\"`")
209    })?;
210
211    // Resolve relative paths against the parent. The leading `.` lets the
212    // user write `.foo` instead of repeating the full prefix on every
213    // method; this expansion happens at compile time so the runtime
214    // entry holds the fully-qualified string.
215    let path_value = path_lit.value();
216    let path_lit = if let Some(suffix) = path_value.strip_prefix('.') {
217        match parent_path {
218            Some(parent) => LitStr::new(&format!("{parent}.{suffix}"), path_lit.span()),
219            None => {
220                return Err(Error::new(
221                    path_lit.span(),
222                    "parity: relative path (leading `.`) requires the enclosing \
223                     `#[parity_impl(...)]` to declare a `path`",
224                ));
225            }
226        }
227    } else {
228        path_lit.clone()
229    };
230
231    let status = args.status.as_ref().ok_or_else(|| {
232        Error::new(
233            span,
234            "parity: missing required `status = Implemented | Partial | Unimplemented`",
235        )
236    })?;
237
238    // `status` is parsed as a bare ident so it can appear as
239    // `::api_parity_rs::Status::#status` (a path, not a string). Validate
240    // here; an invalid one would otherwise produce a confusing
241    // "no variant named X" error from rustc later.
242    if status != "Implemented" && status != "Partial" && status != "Unimplemented" {
243        return Err(Error::new(
244            status.span(),
245            format!(
246                "parity: `status` must be one of `Implemented`, `Partial`, or `Unimplemented` (got `{status}`)"
247            ),
248        ));
249    }
250
251    // Force authors to justify Unimplemented stubs. The whole point of
252    // a stub is that the comment surfaces the reason at the call site.
253    if status == "Unimplemented" && args.comment.is_none() {
254        return Err(Error::new(
255            span,
256            "parity: `status = Unimplemented` requires a `comment = \"...\"` explaining why",
257        ));
258    }
259
260    // ParityEntry stores the optionals as Option<&'static str> / u32, so
261    // we wrap each provided value in `Some(...)` and substitute `None`
262    // otherwise.
263    let since_tok = match &args.since {
264        Some(s) => quote!(Some(#s)),
265        None => quote!(None),
266    };
267    let comment_tok = match &args.comment {
268        Some(s) => quote!(Some(#s)),
269        None => quote!(None),
270    };
271    let issue_tok = match &args.issue {
272        Some(i) => quote!(Some(#i)),
273        None => quote!(None),
274    };
275
276    // Fully-qualified `::api_parity_rs::...` paths so this works no
277    // matter what the user has imported.
278    Ok(quote! {
279        ::api_parity_rs::inventory::submit! {
280            ::api_parity_rs::ParityEntry {
281                path: #path_lit,
282                implementation: #impl_path_expr,
283                status: ::api_parity_rs::Status::#status,
284                since: #since_tok,
285                comment: #comment_tok,
286                issue: #issue_tok,
287            }
288        }
289    })
290}