Skip to main content

oxicode_api_stability/
lib.rs

1// oxicode-api-stability/src/lib.rs
2//! Stability tier attribute macros for the oxicode workspace.
3//!
4//! Provides four attributes that render as colored badges in `cargo doc`:
5//! - `#[stable(since = "0.63.0")]` — green badge, semver-stable
6//! - `#[unstable(feature = "browser")]` — amber badge, may change
7//! - `#[internal]` — hides from docs (`#[doc(hidden)]`)
8//! - `#[deprecated(since = "0.64.0")]` — red badge + native deprecation warning
9
10use proc_macro::TokenStream;
11use quote::quote;
12use syn::{MetaNameValue, parse2};
13
14/// Parsed `since = "0.XX.0"` argument shared by `#[stable]` and `#[deprecated]`.
15#[derive(Debug)]
16struct SinceArg {
17    since: String,
18}
19impl syn::parse::Parse for SinceArg {
20    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
21        let nv: MetaNameValue = input.parse()?;
22        if !matches!(nv.path.get_ident(), Some(id) if id == "since") {
23            return Err(syn::Error::new_spanned(
24                &nv.path,
25                "expected `since = \"...\"`",
26            ));
27        }
28        let since = match nv.value {
29            syn::Expr::Lit(syn::ExprLit {
30                lit: syn::Lit::Str(s),
31                ..
32            }) => s.value(),
33            other => return Err(syn::Error::new_spanned(other, "expected string literal")),
34        };
35        Ok(Self { since })
36    }
37}
38
39/// Parsed `feature = "name"` argument for `#[unstable]`.
40#[derive(Debug)]
41struct FeatureArg {
42    feature: String,
43}
44impl syn::parse::Parse for FeatureArg {
45    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
46        let nv: MetaNameValue = input.parse()?;
47        if !matches!(nv.path.get_ident(), Some(id) if id == "feature") {
48            return Err(syn::Error::new_spanned(
49                &nv.path,
50                "expected `feature = \"...\"`",
51            ));
52        }
53        let feature = match nv.value {
54            syn::Expr::Lit(syn::ExprLit {
55                lit: syn::Lit::Str(s),
56                ..
57            }) => s.value(),
58            other => return Err(syn::Error::new_spanned(other, "expected string literal")),
59        };
60        Ok(Self { feature })
61    }
62}
63
64/// Parsed `since = "0.XX.0", note = "..."` for `#[deprecated(...)]`.
65/// The note is optional (matches the native `#[deprecated]` behavior).
66#[derive(Debug)]
67struct DeprecArg {
68    since: String,
69    note: Option<String>,
70}
71impl syn::parse::Parse for DeprecArg {
72    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
73        let mut since: Option<String> = None;
74        let mut note: Option<String> = None;
75        // Accept: `since = "...", note = "..."` (note is optional, order-independent).
76        while !input.is_empty() {
77            let nv: MetaNameValue = input.parse()?;
78            let value = match nv.value {
79                syn::Expr::Lit(syn::ExprLit {
80                    lit: syn::Lit::Str(s),
81                    ..
82                }) => s.value(),
83                other => return Err(syn::Error::new_spanned(other, "expected string literal")),
84            };
85            match nv.path.get_ident().map(|i| i.to_string()).as_deref() {
86                Some("since") => since = Some(value),
87                Some("note") => note = Some(value),
88                _ => {
89                    return Err(syn::Error::new_spanned(
90                        &nv.path,
91                        "expected `since` or `note`",
92                    ));
93                }
94            }
95            if input.peek(syn::Token![,]) {
96                let _: syn::Token![,] = input.parse()?;
97            }
98        }
99        let since = since.ok_or_else(|| input.error("missing `since = \"...\"`"))?;
100        Ok(Self { since, note })
101    }
102}
103
104/// Attribute that marks an item as semver-stable.
105///
106/// The macro name shadows the built-in `#[stable]` (rustdoc-only)
107/// inside the same use-scope — use the qualified path
108/// `#[oxicode_api_stability::stable(...)]` if you also need the built-in.
109#[proc_macro_attribute]
110pub fn stable(args: TokenStream, input: TokenStream) -> TokenStream {
111    let parsed = match parse2::<SinceArg>(args.into()) {
112        Ok(p) => p,
113        Err(e) => return e.to_compile_error().into(),
114    };
115    let since_val = parsed.since;
116    let input: proc_macro2::TokenStream = input.into();
117    quote! {
118        #[doc = concat!(" <div class=\"stab stable\"><strong>Stable</strong> since ", #since_val, "</div>")]
119        #input
120    }
121    .into()
122}
123
124/// Attribute that marks an item as semver-unstable.
125///
126/// The macro name shadows the built-in `#[unstable]` (rustdoc-only)
127/// inside the same use-scope — use the qualified path
128/// `#[oxicode_api_stability::unstable(...)]` if you also need the built-in.
129#[proc_macro_attribute]
130pub fn unstable(args: TokenStream, input: TokenStream) -> TokenStream {
131    let parsed = match parse2::<FeatureArg>(args.into()) {
132        Ok(p) => p,
133        Err(e) => return e.to_compile_error().into(),
134    };
135    let feature_val = parsed.feature;
136    let input: proc_macro2::TokenStream = input.into();
137    quote! {
138        #[doc = concat!(" <div class=\"stab unstable\"><strong>Unstable</strong> (feature: ", #feature_val, ") — may change or be removed</div>")]
139        #input
140    }
141    .into()
142}
143
144/// Attribute that hides an item from consumer-facing docs.
145#[proc_macro_attribute]
146pub fn internal(_args: TokenStream, input: TokenStream) -> TokenStream {
147    let input: proc_macro2::TokenStream = input.into();
148    quote! {
149        #[doc(hidden)]
150        #input
151    }
152    .into()
153}
154
155/// Attribute that marks an item as deprecated with a doc badge.
156/// Emits the native `#[deprecated(since=..., note=...)]` so consumers don't
157/// need a second attribute. The macro name shadows the built-in `#[deprecated]`
158/// only inside the same use-scope — emit it via the qualified path
159/// `#[oxicode_api_stability::deprecated(...)]` or by importing under a different
160/// name (`use oxicode_api_stability::deprecated as oxicode_deprecated;`) if you also
161/// need the built-in.
162/// Accepts: `since = "0.XX.0"` (required), `note = "..."` (optional).
163#[proc_macro_attribute]
164pub fn deprecated(args: TokenStream, input: TokenStream) -> TokenStream {
165    let parsed = match parse2::<DeprecArg>(args.into()) {
166        Ok(p) => p,
167        Err(e) => return e.to_compile_error().into(),
168    };
169    let since_val = parsed.since;
170    let note_lit = parsed.note.as_deref().unwrap_or("");
171    let input: proc_macro2::TokenStream = input.into();
172    quote! {
173        #[deprecated(since = #since_val, note = #note_lit)]
174        #[doc = concat!(" <div class=\"stab deprecated\"><strong>Deprecated</strong> since ", #since_val, "</div>")]
175        #input
176    }
177    .into()
178}
179
180#[cfg(test)]
181mod tests {
182    // The proc-macro entry points cannot be exercised on test items in this
183    // crate -- `#[stable]` is rejected with E0734 outside the standard library,
184    // and `#[unstable]` / `#[deprecated]` shadow stdlib built-ins (E0659). The
185    // end-to-end usage is covered by downstream integration tests in
186    // oxicode-sdk / oxicode-cli (Phase 2 Task 6+).
187    //
188    // Here we unit-test the parser behavior, which is the only piece testable
189    // from inside the crate. The macro entry points compile because
190    // `cargo build -p oxicode-api-stability` succeeds.
191
192    use super::{DeprecArg, FeatureArg, SinceArg};
193    use proc_macro2::TokenStream;
194    use syn::parse2;
195
196    fn ts(s: &str) -> TokenStream {
197        s.parse().expect("valid token stream")
198    }
199
200    #[test]
201    fn since_arg_parses() {
202        let arg: SinceArg = parse2(ts(r#"since = "0.63.0""#)).unwrap();
203        assert_eq!(arg.since, "0.63.0");
204    }
205
206    #[test]
207    fn since_arg_rejects_wrong_key() {
208        let res: syn::Result<SinceArg> = parse2(ts(r#"feature = "browser""#));
209        assert!(res.is_err(), "expected error for wrong key");
210    }
211
212    #[test]
213    fn feature_arg_parses() {
214        let arg: FeatureArg = parse2(ts(r#"feature = "browser""#)).unwrap();
215        assert_eq!(arg.feature, "browser");
216    }
217
218    #[test]
219    fn feature_arg_rejects_wrong_key() {
220        let res: syn::Result<FeatureArg> = parse2(ts(r#"since = "0.63.0""#));
221        assert!(res.is_err(), "expected error for wrong key");
222    }
223
224    #[test]
225    fn deprec_arg_only_since() {
226        let arg: DeprecArg = parse2(ts(r#"since = "0.64.0""#)).unwrap();
227        assert_eq!(arg.since, "0.64.0");
228        assert_eq!(arg.note, None);
229    }
230
231    #[test]
232    fn deprec_arg_since_and_note() {
233        let arg: DeprecArg = parse2(ts(r#"since = "0.64.0", note = "use new api""#)).unwrap();
234        assert_eq!(arg.since, "0.64.0");
235        assert_eq!(arg.note.as_deref(), Some("use new api"));
236    }
237
238    #[test]
239    fn deprec_arg_note_first_order_independent() {
240        let arg: DeprecArg = parse2(ts(r#"note = "see docs", since = "0.64.0""#)).unwrap();
241        assert_eq!(arg.since, "0.64.0");
242        assert_eq!(arg.note.as_deref(), Some("see docs"));
243    }
244
245    #[test]
246    fn deprec_arg_missing_since_errors() {
247        let res: syn::Result<DeprecArg> = parse2(ts(r#"note = "no since""#));
248        assert!(res.is_err(), "expected error for missing since");
249    }
250
251    #[test]
252    fn deprec_arg_rejects_unknown_key() {
253        let res: syn::Result<DeprecArg> = parse2(ts(r#"reason = "nope""#));
254        assert!(res.is_err(), "expected error for unknown key");
255    }
256
257    #[test]
258    fn deprec_arg_rejects_non_string_value() {
259        let res: syn::Result<DeprecArg> = parse2(ts(r#"since = 42"#));
260        assert!(res.is_err(), "expected error for non-string value");
261    }
262}