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