Skip to main content

miniextendr_macros/miniextendr_impl/
s4_class.rs

1//! S4-class R wrapper generator.
2
3use super::ParsedImpl;
4
5/// Generates the complete R wrapper string for an S4-style class.
6///
7/// Produces the following R code:
8/// - Class definition: `methods::setClass("<class>", slots = c(ptr = "externalptr"))`
9///   with a single `ptr` slot holding the `ExternalPtr` to the Rust struct
10/// - Constructor function: `ClassName(...)` that calls the Rust `new` constructor
11///   and wraps the result with `methods::new("<class>", ptr = .val)`
12/// - S4 generics: `methods::setGeneric(...)` for each instance method (idempotent,
13///   always emitted rather than using conditional `isGeneric()` checks)
14/// - S4 methods: `methods::setMethod("<generic>", "<class>", function(x, ...) ...)`
15///   dispatching to the Rust `.Call()` wrapper, extracting the ptr via `x@ptr`
16/// - Static methods: regular functions named `<class>_<method>(...)`
17///
18/// Roxygen2 `@exportMethod`, `@importFrom methods`, and `@slot` tags are generated
19/// as appropriate.
20pub fn generate_s4_r_wrapper(parsed_impl: &ParsedImpl) -> String {
21    use crate::r_class_formatter::{
22        ClassDocBuilder, MethodContext, MethodDocBuilder, ParsedImplExt, should_export_from_tags,
23    };
24
25    let class_name = parsed_impl.class_name();
26    let type_ident = &parsed_impl.type_ident;
27    let class_doc_tags = &parsed_impl.doc_tags;
28    // Check if class has @noRd - if so, skip method documentation and exports
29    let class_has_no_rd = crate::roxygen::has_roxygen_tag(class_doc_tags, "noRd");
30    let should_export =
31        should_export_from_tags(class_doc_tags, parsed_impl.noexport || parsed_impl.internal);
32
33    let mut lines = Vec::new();
34
35    // Class definition with documentation (S4 uses setClass, no @export on class definition)
36    let has_export = crate::roxygen::has_roxygen_tag(class_doc_tags, "export");
37    lines.extend(
38        ClassDocBuilder::new(&class_name, type_ident, class_doc_tags, "S4")
39            .with_imports("@importFrom methods setClass setGeneric setMethod new")
40            .with_export_control(parsed_impl.internal, parsed_impl.noexport)
41            .build(),
42    );
43    // Inject lifecycle imports from methods into class-level roxygen block
44    if let Some(lc_import) = crate::lifecycle::collect_lifecycle_imports(
45        parsed_impl
46            .methods
47            .iter()
48            .filter_map(|m| m.method_attrs.lifecycle.as_ref()),
49    ) {
50        let insert_pos = lines.len().saturating_sub(1);
51        lines.insert(insert_pos, format!("#' {}", lc_import));
52    }
53    // Remove the @export that ClassDocBuilder adds (S4 doesn't export the class definition)
54    if !has_export {
55        lines.pop();
56    }
57    if !class_has_no_rd {
58        lines.push(format!(
59            "#' @slot ptr External pointer to Rust `{}` struct",
60            type_ident
61        ));
62    }
63    lines.push(format!(
64        "methods::setClass(\"{}\", slots = c(ptr = \"externalptr\"))",
65        class_name
66    ));
67    lines.push(String::new());
68
69    // Constructor function
70    if let Some(ctx) = parsed_impl.constructor_context() {
71        lines.push(ctx.source_comment(type_ident));
72        // Skip documentation if class has @noRd
73        if !class_has_no_rd {
74            // Use class name as @name to avoid duplicate "new" alias across S4 classes
75            let mx_doc = ctx.match_arg_doc_placeholders();
76            let method_doc =
77                MethodDocBuilder::new(&class_name, "new", type_ident, &ctx.method.doc_tags)
78                    .with_r_params(&ctx.params)
79                    .with_match_arg_doc_placeholders(&mx_doc)
80                    .with_r_name(class_name.clone());
81            lines.extend(method_doc.build());
82        }
83        // Export the constructor function so users can create instances (if class should be exported)
84        if should_export {
85            lines.push("#' @export".to_string());
86        }
87
88        lines.push(format!("{} <- function({}) {{", class_name, ctx.params));
89        for line in ctx.missing_prelude() {
90            lines.push(format!("  {}", line));
91        }
92        for check in ctx.precondition_checks() {
93            lines.push(format!("  {}", check));
94        }
95        // Inject match.arg validation for match_arg/choices params
96        for line in ctx.match_arg_prelude() {
97            lines.push(format!("  {}", line));
98        }
99        lines.push(format!("  .val <- {}", ctx.static_call()));
100        lines.extend(crate::method_return_builder::condition_check_lines("  "));
101        lines.push(format!("  methods::new(\"{}\", ptr = .val)", class_name));
102        lines.push("}".to_string());
103        lines.push(String::new());
104    }
105
106    // Instance methods as S4 methods
107    // Note: S4 uses empty param_defaults for method signatures (different from other systems)
108    for method in parsed_impl.instance_methods() {
109        let start = method.ident.span().start();
110        lines.push(format!(
111            "# {}::{} ({}:{})",
112            type_ident,
113            method.ident,
114            start.line,
115            start.column + 1,
116        ));
117        let method_name = if let Some(ref generic) = method.method_attrs.generic {
118            generic.clone()
119        } else {
120            format!("s4_{}", method.ident)
121        };
122        // Build a MethodContext so S4 methods participate in the shared
123        // match_arg prelude + formal-default machinery (#209). The ctx's
124        // `params`/`instance_formals` carry the `c("a", "b")` default for
125        // match_arg'd params, and `match_arg_prelude()` emits the
126        // `base::match.arg()` validation block injected below.
127        let ctx = MethodContext::new(method, type_ident, parsed_impl.label());
128        let call = ctx.instance_call("x@ptr");
129        let full_params = ctx.instance_formals(true);
130
131        // Documentation for the generic - skip if class has @noRd
132        // Use class-qualified @name to avoid duplicate \alias{generic} warnings
133        // when multiple S4 classes share the same generic (e.g., s4_get_value on
134        // both S4TraitCounter and CounterTraitS4). The @exportMethod directive
135        // (added separately) correctly exports the bare generic name.
136        if !class_has_no_rd {
137            let qualified_name = format!("{}-{}", class_name, method_name);
138            let method_doc =
139                MethodDocBuilder::new(&class_name, &method_name, type_ident, &method.doc_tags)
140                    .with_suppress_params()
141                    .with_r_name(qualified_name);
142            let mut doc_lines = method_doc.build();
143            // Add S4 method-specific alias so R CMD check finds the documented method
144            doc_lines.push(format!("#' @aliases {},{}-method", method_name, class_name));
145            lines.extend(doc_lines);
146        }
147
148        // Define generic only if it doesn't already exist. Unconditional setGeneric()
149        // replaces the generic object, clearing previously registered methods. This
150        // matters when multiple types share the same generic name (e.g., s4_get_value
151        // used by both S4TraitCounter and CounterTraitS4).
152        lines.push(format!(
153            "if (!methods::isGeneric(\"{0}\")) methods::setGeneric(\"{0}\", function(x, ...) standardGeneric(\"{0}\"))",
154            method_name
155        ));
156
157        // Define method with @exportMethod for proper S4 dispatch (if class should be exported)
158        if should_export {
159            lines.push(format!("#' @exportMethod {}", method_name));
160        }
161
162        let strategy = crate::ReturnStrategy::for_method(method);
163        let body_lines = crate::MethodReturnBuilder::new(call)
164            .with_strategy(strategy)
165            .with_class_name(class_name.clone())
166            .build_s4_body();
167
168        let what = format!("{}.{}", method_name, class_name);
169        lines.push(format!(
170            "methods::setMethod(\"{}\", \"{}\", function({}) {{",
171            method_name, class_name, full_params
172        ));
173        ctx.emit_method_prelude(&mut lines, "  ", &what);
174        lines.extend(body_lines);
175        lines.push("})".to_string());
176        lines.push(String::new());
177    }
178
179    // Static methods as regular functions
180    for ctx in parsed_impl.static_method_contexts() {
181        lines.push(ctx.source_comment(type_ident));
182        let method_name = ctx.method.r_method_name();
183        let fn_name = format!("{}_{}", class_name, method_name);
184
185        // Skip documentation if class has @noRd
186        if !class_has_no_rd {
187            let mx_doc = ctx.match_arg_doc_placeholders();
188            let method_doc =
189                MethodDocBuilder::new(&class_name, &method_name, type_ident, &ctx.method.doc_tags)
190                    .with_r_params(&ctx.params)
191                    .with_match_arg_doc_placeholders(&mx_doc)
192                    .with_r_name(fn_name.clone());
193            lines.extend(method_doc.build());
194        }
195        // Export static methods so users can call them (if class should be exported)
196        if should_export {
197            lines.push("#' @export".to_string());
198        }
199
200        lines.push(format!("{} <- function({}) {{", fn_name, ctx.params));
201
202        ctx.emit_method_prelude(&mut lines, "  ", &fn_name);
203
204        let strategy = crate::ReturnStrategy::for_method(ctx.method);
205        let return_expr = crate::MethodReturnBuilder::new(ctx.static_call())
206            .with_strategy(strategy)
207            .with_class_name(class_name.clone())
208            .build_s4_inline();
209        lines.push(format!("  {}", return_expr));
210
211        lines.push("}".to_string());
212        lines.push(String::new());
213    }
214
215    lines.join("\n")
216}