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}