Skip to main content

miniextendr_macros/
lifecycle.rs

1//! Lifecycle support for R package deprecation management.
2//!
3//! This module provides integration with R's `lifecycle` package for managing
4//! experimental, deprecated, and superseded functions.
5//!
6//! # Usage
7//!
8//! Mark functions with lifecycle attributes:
9//!
10//! ```rust,ignore
11//! // Using Rust's deprecated attribute
12//! #[deprecated(since = "0.4.0", note = "Use new_fn() instead")]
13//! #[miniextendr]
14//! pub fn old_fn(x: i32) -> i32 { x }
15//!
16//! // Using miniextendr's lifecycle attribute
17//! #[miniextendr(lifecycle = "experimental")]
18//! pub fn new_fn(x: i32) -> i32 { x * 2 }
19//! ```
20//!
21//! # Lifecycle Stages
22//!
23//! - `experimental`: Function is experimental and may change without notice
24//! - `stable`: Function is stable (default, no badge/warning needed)
25//! - `superseded`: Function has a better alternative but will be maintained
26//! - `deprecated`: Function should no longer be used and may be removed
27//! - `defunct`: Function no longer works and throws an error
28
29use std::fmt;
30
31/// Lifecycle stage for a function, method, or argument.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum LifecycleStage {
34    /// Function is experimental and may change.
35    Experimental,
36    /// Function is stable (no badge/warning needed).
37    #[default]
38    Stable,
39    /// Function has a better alternative but will be maintained.
40    Superseded,
41    /// Function should no longer be used (soft warning first).
42    SoftDeprecated,
43    /// Function should no longer be used (warning).
44    Deprecated,
45    /// Function no longer works (error).
46    Defunct,
47}
48
49impl LifecycleStage {
50    /// Parse a lifecycle stage from a string.
51    ///
52    /// Accepts lowercase stage names. Both `"soft-deprecated"` and `"soft_deprecated"`
53    /// are recognized. Returns `None` for unrecognized strings.
54    pub fn from_str(s: &str) -> Option<Self> {
55        match s.to_lowercase().as_str() {
56            "experimental" => Some(Self::Experimental),
57            "stable" => Some(Self::Stable),
58            "superseded" => Some(Self::Superseded),
59            "soft-deprecated" | "soft_deprecated" => Some(Self::SoftDeprecated),
60            "deprecated" => Some(Self::Deprecated),
61            "defunct" => Some(Self::Defunct),
62            _ => None,
63        }
64    }
65
66    /// Get the inline R roxygen expression for the lifecycle badge.
67    ///
68    /// Returns an R inline code expression like `` `r lifecycle::badge("experimental")` ``
69    /// that roxygen2 evaluates to render a colored badge. Returns `None` for `Stable`
70    /// (no badge needed).
71    pub fn badge(&self) -> Option<&'static str> {
72        match self {
73            Self::Experimental => Some(r#"`r lifecycle::badge("experimental")`"#),
74            Self::Stable => None, // No badge for stable
75            Self::Superseded => Some(r#"`r lifecycle::badge("superseded")`"#),
76            Self::SoftDeprecated | Self::Deprecated => {
77                Some(r#"`r lifecycle::badge("deprecated")`"#)
78            }
79            Self::Defunct => Some(r#"`r lifecycle::badge("deprecated")`"#),
80        }
81    }
82
83    /// Get the fully-qualified lifecycle signal function name.
84    ///
85    /// Returns the R function to call at the start of the wrapper body to emit
86    /// the lifecycle signal (e.g., `"lifecycle::deprecate_warn"`). Returns `None`
87    /// for `Stable` (no signal needed).
88    pub fn signal_fn(&self) -> Option<&'static str> {
89        match self {
90            Self::Experimental => Some("lifecycle::signal_stage"),
91            Self::Stable => None,
92            Self::Superseded => Some("lifecycle::signal_stage"),
93            Self::SoftDeprecated => Some("lifecycle::deprecate_soft"),
94            Self::Deprecated => Some("lifecycle::deprecate_warn"),
95            Self::Defunct => Some("lifecycle::deprecate_stop"),
96        }
97    }
98
99    /// Get the bare R function name for `@importFrom lifecycle` roxygen tag.
100    ///
101    /// Returns the function name without the `lifecycle::` prefix.
102    pub fn import_from_fn(&self) -> Option<&'static str> {
103        match self {
104            Self::Experimental | Self::Superseded => Some("signal_stage"),
105            Self::Stable => None,
106            Self::SoftDeprecated => Some("deprecate_soft"),
107            Self::Deprecated => Some("deprecate_warn"),
108            Self::Defunct => Some("deprecate_stop"),
109        }
110    }
111
112    /// Get the roxygen `@keywords` value, if this stage needs one.
113    ///
114    /// Only `Experimental` adds `@keywords internal` to keep the function
115    /// off the main package index. Returns `None` for all other stages.
116    pub fn keywords(&self) -> Option<&'static str> {
117        match self {
118            Self::Experimental => Some("internal"),
119            Self::Stable => None,
120            Self::Superseded => None,
121            Self::SoftDeprecated | Self::Deprecated | Self::Defunct => None,
122        }
123    }
124}
125
126impl fmt::Display for LifecycleStage {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            Self::Experimental => write!(f, "experimental"),
130            Self::Stable => write!(f, "stable"),
131            Self::Superseded => write!(f, "superseded"),
132            Self::SoftDeprecated => write!(f, "soft-deprecated"),
133            Self::Deprecated => write!(f, "deprecated"),
134            Self::Defunct => write!(f, "defunct"),
135        }
136    }
137}
138
139/// Full lifecycle specification for a function or method.
140#[derive(Debug, Clone, Default)]
141pub struct LifecycleSpec {
142    /// The lifecycle stage.
143    pub stage: LifecycleStage,
144    /// Version when the lifecycle change occurred (e.g., "0.4.0").
145    pub when: Option<String>,
146    /// What is being deprecated (e.g., "old_fn()" or "old_fn(arg)").
147    /// Auto-inferred from function name if not provided.
148    pub what: Option<String>,
149    /// Replacement to suggest (e.g., "new_fn()").
150    pub with: Option<String>,
151    /// Additional details message.
152    pub details: Option<String>,
153    /// Unique ID for lifecycle deprecation tracking.
154    pub id: Option<String>,
155}
156
157impl LifecycleSpec {
158    /// Create a new lifecycle spec with the given stage and no additional metadata.
159    pub fn new(stage: LifecycleStage) -> Self {
160        Self {
161            stage,
162            ..Default::default()
163        }
164    }
165
166    /// Create a lifecycle spec from a Rust `#[deprecated]` attribute.
167    ///
168    /// Maps the `since` field to `when` and attempts to parse the `note` field
169    /// for a "use X instead" pattern to populate `with`. The full note is also
170    /// stored in `details`.
171    pub fn from_deprecated(since: Option<&str>, note: Option<&str>) -> Self {
172        let mut spec = Self::new(LifecycleStage::Deprecated);
173        spec.when = since.map(String::from);
174
175        // Try to parse "with" from note if it contains "use X instead" pattern
176        if let Some(note) = note {
177            if let Some(rest) = note.to_lowercase().strip_prefix("use ") {
178                if let Some(end) = rest.find(" instead") {
179                    spec.with = Some(rest[..end].to_string());
180                } else {
181                    spec.with = Some(rest.to_string());
182                }
183            }
184            spec.details = Some(note.to_string());
185        }
186
187        spec
188    }
189
190    /// Generate the R prelude code for lifecycle signaling.
191    ///
192    /// Returns a single line of R code to insert at the start of the function body,
193    /// or `None` for `Stable` stage. The `fn_name` is used as the `what` argument
194    /// if no explicit `what` was provided.
195    ///
196    /// For experimental/superseded: `lifecycle::signal_stage("stage", "fn_name()")`.
197    /// For deprecated variants: `lifecycle::deprecate_*(when, what, with, details, id)`.
198    pub fn r_prelude(&self, fn_name: &str) -> Option<String> {
199        let signal_fn = self.stage.signal_fn()?;
200
201        let what = self.what.as_deref().unwrap_or(fn_name);
202
203        match self.stage {
204            LifecycleStage::Experimental | LifecycleStage::Superseded => {
205                // lifecycle::signal_stage("experimental", "fn_name()")
206                Some(format!("{}(\"{}\", \"{}()\")", signal_fn, self.stage, what))
207            }
208            LifecycleStage::SoftDeprecated
209            | LifecycleStage::Deprecated
210            | LifecycleStage::Defunct => {
211                // lifecycle::deprecate_*(when, what, with, details, id)
212                let when = self.when.as_deref().unwrap_or("0.0.0");
213                let what_arg = format!("\"{}()\"", what);
214                let with_arg = self
215                    .with
216                    .as_ref()
217                    .map(|w| format!(", \"{}\"", w))
218                    .unwrap_or_default();
219                let details_arg = self
220                    .details
221                    .as_ref()
222                    .map(|d| format!(", details = \"{}\"", d.replace('"', "\\\"")))
223                    .unwrap_or_default();
224                let id_arg = self
225                    .id
226                    .as_ref()
227                    .map(|id| format!(", id = \"{}\"", id))
228                    .unwrap_or_default();
229
230                Some(format!(
231                    "{}(\"{}\", {}{}{}{})",
232                    signal_fn, when, what_arg, with_arg, details_arg, id_arg
233                ))
234            }
235            LifecycleStage::Stable => None,
236        }
237    }
238}
239
240/// Collect the `@importFrom lifecycle ...` roxygen tag needed for a set of lifecycle specs.
241///
242/// This is used by class generators (R6, env, S3, S4, S7) to aggregate lifecycle
243/// imports from all methods and include them in the class-level roxygen block.
244/// Returns `None` if no lifecycle imports are needed.
245pub fn collect_lifecycle_imports<'a>(
246    specs: impl Iterator<Item = &'a LifecycleSpec>,
247) -> Option<String> {
248    let mut fns = std::collections::BTreeSet::new();
249    for spec in specs {
250        if spec.stage.badge().is_some() {
251            fns.insert("badge");
252        }
253        if let Some(fn_name) = spec.stage.import_from_fn() {
254            fns.insert(fn_name);
255        }
256    }
257    if fns.is_empty() {
258        None
259    } else {
260        let mut import = String::from("@importFrom lifecycle");
261        for f in fns {
262            import.push(' ');
263            import.push_str(f);
264        }
265        Some(import)
266    }
267}
268
269/// Parse lifecycle spec from miniextendr attribute arguments.
270///
271/// Supports:
272/// - `lifecycle = "deprecated"` (simple stage)
273/// - `lifecycle(stage = "deprecated", when = "0.4.0", with = "new_fn()")` (full spec)
274pub fn parse_lifecycle_attr(meta: &syn::Meta) -> syn::Result<Option<LifecycleSpec>> {
275    use syn::spanned::Spanned;
276
277    match meta {
278        syn::Meta::NameValue(nv) if nv.path.is_ident("lifecycle") => {
279            // lifecycle = "stage"
280            if let syn::Expr::Lit(syn::ExprLit {
281                lit: syn::Lit::Str(lit),
282                ..
283            }) = &nv.value
284            {
285                let stage = LifecycleStage::from_str(&lit.value()).ok_or_else(|| {
286                    syn::Error::new_spanned(
287                        lit,
288                        "invalid lifecycle stage; expected one of: experimental, stable, superseded, soft-deprecated, deprecated, defunct",
289                    )
290                })?;
291                return Ok(Some(LifecycleSpec::new(stage)));
292            }
293            Err(syn::Error::new_spanned(
294                &nv.value,
295                "lifecycle expects a string literal",
296            ))
297        }
298        syn::Meta::List(list) if list.path.is_ident("lifecycle") => {
299            // lifecycle(stage = "deprecated", when = "0.4.0", ...)
300            let mut spec = LifecycleSpec::default();
301
302            let nested: syn::punctuated::Punctuated<syn::Meta, syn::Token![,]> =
303                list.parse_args_with(syn::punctuated::Punctuated::parse_terminated)?;
304
305            for meta in nested {
306                if let syn::Meta::NameValue(nv) = meta {
307                    let key = nv.path.get_ident().map(|i| i.to_string());
308                    let value = match &nv.value {
309                        syn::Expr::Lit(expr_lit) => {
310                            if let syn::Lit::Str(lit) = &expr_lit.lit {
311                                lit.value()
312                            } else {
313                                return Err(syn::Error::new_spanned(
314                                    &nv.value,
315                                    "expected string literal",
316                                ));
317                            }
318                        }
319                        _ => {
320                            return Err(syn::Error::new_spanned(
321                                &nv.value,
322                                "expected string literal",
323                            ));
324                        }
325                    };
326
327                    match key.as_deref() {
328                        Some("stage") => {
329                            spec.stage = LifecycleStage::from_str(&value).ok_or_else(|| {
330                                syn::Error::new(nv.value.span(), "invalid lifecycle stage")
331                            })?;
332                        }
333                        Some("when") => spec.when = Some(value),
334                        Some("what") => spec.what = Some(value),
335                        Some("with") => spec.with = Some(value),
336                        Some("details") => spec.details = Some(value),
337                        Some("id") => spec.id = Some(value),
338                        _ => {
339                            return Err(syn::Error::new_spanned(
340                                &nv.path,
341                                "unknown lifecycle option; expected: stage, when, what, with, details, id",
342                            ));
343                        }
344                    }
345                }
346            }
347
348            Ok(Some(spec))
349        }
350        _ => Ok(None),
351    }
352}
353
354/// Extract lifecycle info from a `#[deprecated]` attribute.
355///
356/// Handles all three forms: `#[deprecated]`, `#[deprecated = "msg"]`,
357/// and `#[deprecated(since = "...", note = "...")]`. Returns `None` if the
358/// attribute is not `#[deprecated]`.
359pub fn parse_rust_deprecated(attr: &syn::Attribute) -> Option<LifecycleSpec> {
360    if !attr.path().is_ident("deprecated") {
361        return None;
362    }
363
364    let mut since = None;
365    let mut note = None;
366
367    match &attr.meta {
368        syn::Meta::Path(_) => {
369            // Just #[deprecated]
370        }
371        syn::Meta::NameValue(nv) => {
372            // #[deprecated = "message"]
373            if let syn::Expr::Lit(syn::ExprLit {
374                lit: syn::Lit::Str(lit),
375                ..
376            }) = &nv.value
377            {
378                note = Some(lit.value());
379            }
380        }
381        syn::Meta::List(list) => {
382            // #[deprecated(since = "...", note = "...")]
383            if let Ok(nested) = list.parse_args_with(
384                syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated,
385            ) {
386                for meta in nested {
387                    if let syn::Meta::NameValue(nv) = meta
388                        && let syn::Expr::Lit(syn::ExprLit {
389                            lit: syn::Lit::Str(lit),
390                            ..
391                        }) = &nv.value
392                    {
393                        if nv.path.is_ident("since") {
394                            since = Some(lit.value());
395                        } else if nv.path.is_ident("note") {
396                            note = Some(lit.value());
397                        }
398                    }
399                }
400            }
401        }
402    }
403
404    Some(LifecycleSpec::from_deprecated(
405        since.as_deref(),
406        note.as_deref(),
407    ))
408}
409
410/// Inject lifecycle badge into roxygen tags if not already present.
411///
412/// Modifies the tags in place, prepending the badge to @description if present,
413/// or adding a new @description tag with just the badge.
414pub fn inject_lifecycle_badge(tags: &mut Vec<String>, spec: &LifecycleSpec) {
415    let Some(badge) = spec.stage.badge() else {
416        return;
417    };
418
419    // Check if there's already a description tag
420    let desc_idx = tags.iter().position(|t| t.starts_with("@description"));
421
422    if let Some(idx) = desc_idx {
423        // Prepend badge to existing description
424        let existing = &tags[idx];
425        let desc_content = existing
426            .strip_prefix("@description")
427            .unwrap_or("")
428            .trim_start();
429        tags[idx] = format!("@description {} {}", badge, desc_content);
430    } else {
431        // Insert new description with just the badge at the start
432        tags.insert(0, format!("@description {}", badge));
433    }
434
435    // Add keywords if needed and not present
436    if let Some(keywords) = spec.stage.keywords()
437        && !tags.iter().any(|t| t.starts_with("@keywords"))
438    {
439        tags.push(format!("@keywords {}", keywords));
440    }
441
442    // Add @importFrom for the lifecycle signal function and badge
443    inject_lifecycle_imports(tags, spec);
444}
445
446/// Inject `@importFrom lifecycle` roxygen tags for the signal function and badge.
447///
448/// Adds the necessary `@importFrom` tag so roxygen2 registers the lifecycle
449/// dependency in NAMESPACE. This is only added if not already present.
450pub fn inject_lifecycle_imports(tags: &mut Vec<String>, spec: &LifecycleSpec) {
451    // Collect the lifecycle functions we need to import
452    let mut fns_to_import = Vec::new();
453
454    // The badge inline R code uses lifecycle::badge()
455    if spec.stage.badge().is_some() {
456        fns_to_import.push("badge");
457    }
458
459    // The runtime signal function
460    if let Some(fn_name) = spec.stage.import_from_fn() {
461        fns_to_import.push(fn_name);
462    }
463
464    if fns_to_import.is_empty() {
465        return;
466    }
467
468    // Deduplicate against already-present @importFrom lifecycle tags
469    let already_imported: Vec<&str> = tags
470        .iter()
471        .filter_map(|t| t.strip_prefix("@importFrom lifecycle "))
472        .flat_map(|s| s.split_whitespace())
473        .collect();
474
475    fns_to_import.retain(|f| !already_imported.contains(f));
476
477    if !fns_to_import.is_empty() {
478        tags.push(format!("@importFrom lifecycle {}", fns_to_import.join(" ")));
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn test_stage_from_str() {
488        assert_eq!(
489            LifecycleStage::from_str("experimental"),
490            Some(LifecycleStage::Experimental)
491        );
492        assert_eq!(
493            LifecycleStage::from_str("deprecated"),
494            Some(LifecycleStage::Deprecated)
495        );
496        assert_eq!(
497            LifecycleStage::from_str("soft-deprecated"),
498            Some(LifecycleStage::SoftDeprecated)
499        );
500        assert_eq!(LifecycleStage::from_str("unknown"), None);
501    }
502
503    #[test]
504    fn test_r_prelude_deprecated() {
505        let spec = LifecycleSpec {
506            stage: LifecycleStage::Deprecated,
507            when: Some("0.4.0".into()),
508            what: None,
509            with: Some("new_fn()".into()),
510            details: None,
511            id: None,
512        };
513        let prelude = spec.r_prelude("old_fn").unwrap();
514        assert!(prelude.contains("lifecycle::deprecate_warn"));
515        assert!(prelude.contains("0.4.0"));
516        assert!(prelude.contains("old_fn()"));
517        assert!(prelude.contains("new_fn()"));
518    }
519
520    #[test]
521    fn test_r_prelude_experimental() {
522        let spec = LifecycleSpec::new(LifecycleStage::Experimental);
523        let prelude = spec.r_prelude("my_fn").unwrap();
524        assert!(prelude.contains("lifecycle::signal_stage"));
525        assert!(prelude.contains("experimental"));
526        assert!(prelude.contains("my_fn()"));
527    }
528
529    #[test]
530    fn test_from_deprecated_note() {
531        let spec = LifecycleSpec::from_deprecated(Some("1.0.0"), Some("Use bar() instead"));
532        assert_eq!(spec.stage, LifecycleStage::Deprecated);
533        assert_eq!(spec.when, Some("1.0.0".into()));
534        assert_eq!(spec.with, Some("bar()".into()));
535    }
536
537    #[test]
538    fn test_import_from_fn() {
539        assert_eq!(
540            LifecycleStage::Experimental.import_from_fn(),
541            Some("signal_stage")
542        );
543        assert_eq!(LifecycleStage::Stable.import_from_fn(), None);
544        assert_eq!(
545            LifecycleStage::Superseded.import_from_fn(),
546            Some("signal_stage")
547        );
548        assert_eq!(
549            LifecycleStage::SoftDeprecated.import_from_fn(),
550            Some("deprecate_soft")
551        );
552        assert_eq!(
553            LifecycleStage::Deprecated.import_from_fn(),
554            Some("deprecate_warn")
555        );
556        assert_eq!(
557            LifecycleStage::Defunct.import_from_fn(),
558            Some("deprecate_stop")
559        );
560    }
561
562    #[test]
563    fn test_inject_lifecycle_imports_deprecated() {
564        let spec = LifecycleSpec::new(LifecycleStage::Deprecated);
565        let mut tags = vec!["@title My function".to_string()];
566        inject_lifecycle_badge(&mut tags, &spec);
567        // Should have @importFrom lifecycle badge deprecate_warn
568        let import_tag = tags
569            .iter()
570            .find(|t| t.starts_with("@importFrom lifecycle"))
571            .expect("should have @importFrom lifecycle tag");
572        assert!(import_tag.contains("badge"));
573        assert!(import_tag.contains("deprecate_warn"));
574    }
575
576    #[test]
577    fn test_inject_lifecycle_imports_experimental() {
578        let spec = LifecycleSpec::new(LifecycleStage::Experimental);
579        let mut tags = vec!["@title My function".to_string()];
580        inject_lifecycle_badge(&mut tags, &spec);
581        let import_tag = tags
582            .iter()
583            .find(|t| t.starts_with("@importFrom lifecycle"))
584            .expect("should have @importFrom lifecycle tag");
585        assert!(import_tag.contains("badge"));
586        assert!(import_tag.contains("signal_stage"));
587    }
588
589    #[test]
590    fn test_inject_lifecycle_imports_stable_no_import() {
591        let spec = LifecycleSpec::new(LifecycleStage::Stable);
592        let mut tags = vec!["@title My function".to_string()];
593        inject_lifecycle_badge(&mut tags, &spec);
594        // Stable stage should not add any @importFrom
595        assert!(!tags.iter().any(|t| t.starts_with("@importFrom")));
596    }
597
598    #[test]
599    fn test_inject_lifecycle_imports_no_duplicates() {
600        let spec = LifecycleSpec::new(LifecycleStage::Deprecated);
601        let mut tags = vec![
602            "@title My function".to_string(),
603            "@importFrom lifecycle deprecate_warn badge".to_string(),
604        ];
605        inject_lifecycle_badge(&mut tags, &spec);
606        // Should not add a duplicate @importFrom tag
607        let import_count = tags
608            .iter()
609            .filter(|t| t.starts_with("@importFrom lifecycle"))
610            .count();
611        assert_eq!(import_count, 1);
612    }
613
614    #[test]
615    fn test_collect_lifecycle_imports_mixed_methods() {
616        let specs = [
617            LifecycleSpec::new(LifecycleStage::Deprecated),
618            LifecycleSpec::new(LifecycleStage::Experimental),
619            LifecycleSpec::new(LifecycleStage::Stable),
620        ];
621        let result = collect_lifecycle_imports(specs.iter());
622        let import = result.expect("should produce import tag");
623        // BTreeSet gives sorted order: badge, deprecate_warn, signal_stage
624        assert_eq!(
625            import,
626            "@importFrom lifecycle badge deprecate_warn signal_stage"
627        );
628    }
629
630    #[test]
631    fn test_collect_lifecycle_imports_no_lifecycle() {
632        let specs: Vec<LifecycleSpec> = vec![];
633        let result = collect_lifecycle_imports(specs.iter());
634        assert!(result.is_none());
635    }
636
637    #[test]
638    fn test_collect_lifecycle_imports_only_stable() {
639        let specs = [LifecycleSpec::new(LifecycleStage::Stable)];
640        let result = collect_lifecycle_imports(specs.iter());
641        assert!(result.is_none());
642    }
643}