Skip to main content

miniextendr_macros/miniextendr_impl/
s4_class.rs

1//! S4-class R wrapper generator.
2//!
3//! Generates `methods::setClass(...)` with an `externalptr` slot, plus
4//! `methods::setGeneric` / `methods::setMethod` for each instance method.
5//! Supports **formal slot validation, multi-dispatch on method signatures,
6//! and contains-based inheritance** — the only system here with native
7//! multi-dispatch. Cost: slowest dispatch path, all helpers live in the
8//! `methods::` namespace (`methods` must be imported, not `base`), and the
9//! ecosystem is increasingly legacy. Pick S4 for Bioconductor interop;
10//! use S7 for new packages wanting similar formal semantics.
11
12use super::ParsedImpl;
13
14/// Generates the complete R wrapper string for an S4-style class.
15///
16/// Produces the following R code:
17/// - Class definition: `methods::setClass("<class>", slots = c(ptr = "externalptr"))`
18///   with a single `ptr` slot holding the `ExternalPtr` to the Rust struct
19/// - Constructor function: `ClassName(...)` that calls the Rust `new` constructor
20///   and wraps the result with `methods::new("<class>", ptr = .val)`
21/// - S4 generics: `methods::setGeneric(...)` for each instance method, guarded by
22///   a namespace-local `exists()` check (see #1158 for why not `isGeneric()`)
23/// - S4 methods: `methods::setMethod("<generic>", "<class>", function(x, ...) ...)`
24///   dispatching to the Rust `.Call()` wrapper, extracting the ptr via `x@ptr`
25/// - Static methods: regular functions named `<class>_<method>(...)`
26///
27/// Roxygen2 `@exportMethod`, `@importFrom methods`, and `@slot` tags are generated
28/// as appropriate.
29pub fn generate_s4_r_wrapper(parsed_impl: &ParsedImpl) -> String {
30    use crate::r_class_formatter::{
31        ClassDocBuilder, MethodContext, MethodDocBuilder, ParsedImplExt, should_export_from_tags,
32    };
33
34    let class_name = parsed_impl.class_name();
35    let type_ident = &parsed_impl.type_ident;
36    let class_doc_tags = &parsed_impl.doc_tags;
37    // Check if class has @noRd - if so, skip method documentation and exports. A
38    // plain `noexport` (without `internal`) is folded in too — it must suppress
39    // Rd contribution entirely, matching `ClassDocBuilder::build`'s `suppress_rd`
40    // gate. `should_export` (below) already independently gates @export/@exportMethod.
41    let class_has_no_rd = crate::roxygen::has_roxygen_tag(class_doc_tags, "noRd")
42        || (parsed_impl.noexport && !parsed_impl.internal);
43    let should_export =
44        should_export_from_tags(class_doc_tags, parsed_impl.noexport || parsed_impl.internal);
45
46    let mut lines = Vec::new();
47
48    // Class definition with documentation (S4 uses setClass, no @export on class definition)
49    let has_export = crate::roxygen::has_roxygen_tag(class_doc_tags, "export");
50    lines.extend(
51        ClassDocBuilder::new(&class_name, type_ident, class_doc_tags, "S4")
52            .with_imports("@importFrom methods setClass setGeneric setMethod new")
53            .with_export_control(parsed_impl.internal, parsed_impl.noexport)
54            .build(),
55    );
56    // Inject lifecycle imports from methods into class-level roxygen block
57    if let Some(lc_import) = crate::lifecycle::collect_lifecycle_imports(
58        parsed_impl
59            .methods
60            .iter()
61            .filter_map(|m| m.method_attrs.lifecycle.as_ref()),
62    ) {
63        let insert_pos = lines.len().saturating_sub(1);
64        lines.insert(insert_pos, format!("#' {}", lc_import));
65    }
66    // Remove the @export that ClassDocBuilder adds (S4 doesn't export the class
67    // definition). Only pop when the last line actually IS the auto-added
68    // @export — with internal/noexport the builder emits no @export, and a
69    // blind pop would instead drop `@keywords internal` / `@noRd` / a user tag.
70    if !has_export && lines.last().is_some_and(|l| l == "#' @export") {
71        lines.pop();
72    }
73    if !class_has_no_rd {
74        lines.push(format!(
75            "#' @slot ptr External pointer to Rust `{}` struct",
76            type_ident
77        ));
78    }
79    lines.push(format!(
80        "methods::setClass(\"{}\", slots = c(ptr = \"externalptr\"))",
81        class_name
82    ));
83    lines.push(String::new());
84
85    // Constructor function
86    if let Some(ctx) = parsed_impl.constructor_context() {
87        lines.push(ctx.source_comment(type_ident));
88        // Skip documentation if class has @noRd
89        if !class_has_no_rd {
90            // Use class name as @name to avoid duplicate "new" alias across S4 classes
91            let mx_doc = ctx.match_arg_doc_placeholders();
92            let method_doc =
93                MethodDocBuilder::new(&class_name, "new", type_ident, &ctx.method.doc_tags)
94                    .with_r_params(&ctx.params)
95                    .with_match_arg_doc_placeholders(&mx_doc)
96                    .with_r_name(class_name.clone());
97            lines.extend(method_doc.build());
98        }
99        // Export the constructor function so users can create instances (if class should be exported)
100        if should_export {
101            lines.push("#' @export".to_string());
102        }
103
104        lines.push(format!("{} <- function({}) {{", class_name, ctx.params));
105        for check in ctx.precondition_checks() {
106            lines.push(format!("  {}", check));
107        }
108        // Inject match.arg validation for match_arg/choices params
109        for line in ctx.match_arg_prelude() {
110            lines.push(format!("  {}", line));
111        }
112        lines.push(format!("  .val <- {}", ctx.static_call()));
113        lines.extend(crate::method_return_builder::condition_check_lines("  "));
114        lines.push(format!("  methods::new(\"{}\", ptr = .val)", class_name));
115        lines.push("}".to_string());
116        lines.push(String::new());
117    }
118
119    // Instance methods as S4 methods
120    // Note: S4 uses empty param_defaults for method signatures (different from other systems)
121    for method in parsed_impl.instance_methods() {
122        let start = method.ident.span().start();
123        let method_name = if let Some(ref generic) = method.method_attrs.generic {
124            generic.clone()
125        } else {
126            format!("s4_{}", method.ident)
127        };
128
129        // Emit the generic-doc marker BEFORE the source comment so the write-time
130        // pass can place the standalone Rd page before the method block.  This
131        // ensures the synthesised doc block (ending with NULL) is separated from
132        // the method's own roxygen comment by the `# source` line.
133        if !class_has_no_rd {
134            lines.push(format!(
135                ".__MX_GENERIC_DOC__(kind=\"S4\", generic=\"{method_name}\", class=\"{class_name}\", export={should_export})"
136            ));
137        }
138
139        lines.push(format!(
140            "# {}::{} ({}:{})",
141            type_ident,
142            method.ident,
143            start.line,
144            start.column + 1,
145        ));
146        // Build a MethodContext so S4 methods participate in the shared
147        // match_arg prelude + formal-default machinery (#209). The ctx's
148        // `params`/`instance_formals` carry the `c("a", "b")` default for
149        // match_arg'd params, and `match_arg_prelude()` emits the
150        // `base::match.arg()` validation block injected below.
151        let ctx = MethodContext::new(method, type_ident, parsed_impl.label()).with_fast_flags(
152            parsed_impl.no_preconditions,
153            parsed_impl.no_call_attribution,
154        );
155        let call = ctx.instance_call("x@ptr");
156        let full_params = ctx.instance_formals(true);
157
158        // Documentation for the generic - skip if class has @noRd
159        // Use class-qualified @name to avoid duplicate \alias{generic} warnings
160        // when multiple S4 classes share the same generic (e.g., s4_get_value on
161        // both S4TraitCounter and CounterTraitS4). The @exportMethod directive
162        // (added separately) correctly exports the bare generic name.
163        if !class_has_no_rd {
164            let qualified_name = format!("{}-{}", class_name, method_name);
165            let method_doc =
166                MethodDocBuilder::new(&class_name, &method_name, type_ident, &method.doc_tags)
167                    .with_suppress_params()
168                    .with_r_name(qualified_name);
169            let mut doc_lines = method_doc.build();
170            // Add S4 method-specific alias so R CMD check finds the documented method
171            doc_lines.push(format!("#' @aliases {},{}-method", method_name, class_name));
172            lines.extend(doc_lines);
173        }
174
175        // Define generic only if it doesn't already exist IN THIS NAMESPACE.
176        // Unconditional setGeneric() replaces the generic object, clearing
177        // previously registered methods — that matters when multiple types share
178        // the same generic name (e.g., s4_get_value used by both S4TraitCounter
179        // and CounterTraitS4). The check must be a namespace-local exists():
180        // a bare isGeneric() searches S4 metadata globally, so with an installed
181        // copy of the package *attached*, load_all() skips setGeneric here while
182        // setMethod below still can't see the generic from the namespace being
183        // loaded — "no existing definition for function ..." — and
184        // isGeneric(where=) resolves the namespace to a package name, which
185        // fails mid-install (findpack). exists() is a plain env lookup and the
186        // generic function is exactly what setGeneric assigns there (#1158).
187        lines.push(format!(
188            "if (!exists(\"{0}\", where = topenv(environment()), inherits = FALSE)) methods::setGeneric(\"{0}\", function(x, ...) standardGeneric(\"{0}\"))",
189            method_name
190        ));
191
192        // Define method with @exportMethod for proper S4 dispatch (if class should be exported)
193        if should_export {
194            lines.push(format!("#' @exportMethod {}", method_name));
195        }
196
197        let strategy = crate::ReturnStrategy::for_method(method);
198        let body_lines = crate::MethodReturnBuilder::new(call)
199            .with_strategy(strategy)
200            .with_class_name(class_name.clone())
201            .with_return_class_from_method(method)
202            .build_s4_body();
203
204        let what = format!("{}.{}", method_name, class_name);
205        lines.push(format!(
206            "methods::setMethod(\"{}\", \"{}\", function({}) {{",
207            method_name, class_name, full_params
208        ));
209        ctx.emit_method_prelude(&mut lines, "  ", &what);
210        lines.extend(body_lines);
211        lines.push("})".to_string());
212        lines.push(String::new());
213    }
214
215    // Static methods as regular functions
216    for ctx in parsed_impl.static_method_contexts() {
217        lines.push(ctx.source_comment(type_ident));
218        let method_name = ctx.method.r_method_name();
219        let fn_name = format!("{}_{}", class_name, method_name);
220
221        // Skip documentation if class has @noRd
222        if !class_has_no_rd {
223            let mx_doc = ctx.match_arg_doc_placeholders();
224            let method_doc =
225                MethodDocBuilder::new(&class_name, &method_name, type_ident, &ctx.method.doc_tags)
226                    .with_r_params(&ctx.params)
227                    .with_match_arg_doc_placeholders(&mx_doc)
228                    .with_r_name(fn_name.clone());
229            lines.extend(method_doc.build());
230        }
231        // Export static methods so users can call them (if class should be exported)
232        if should_export {
233            lines.push("#' @export".to_string());
234        }
235
236        lines.push(format!("{} <- function({}) {{", fn_name, ctx.params));
237
238        ctx.emit_method_prelude(&mut lines, "  ", &fn_name);
239
240        let strategy = crate::ReturnStrategy::for_method(ctx.method);
241        let return_expr = crate::MethodReturnBuilder::new(ctx.static_call())
242            .with_strategy(strategy)
243            .with_class_name(class_name.clone())
244            .with_return_class_from_method(ctx.method)
245            .build_s4_inline();
246        lines.push(format!("  {}", return_expr));
247
248        lines.push("}".to_string());
249        lines.push(String::new());
250    }
251
252    lines.join("\n")
253}