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}