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());
152        let call = ctx.instance_call("x@ptr");
153        let full_params = ctx.instance_formals(true);
154
155        // Documentation for the generic - skip if class has @noRd
156        // Use class-qualified @name to avoid duplicate \alias{generic} warnings
157        // when multiple S4 classes share the same generic (e.g., s4_get_value on
158        // both S4TraitCounter and CounterTraitS4). The @exportMethod directive
159        // (added separately) correctly exports the bare generic name.
160        if !class_has_no_rd {
161            let qualified_name = format!("{}-{}", class_name, method_name);
162            let method_doc =
163                MethodDocBuilder::new(&class_name, &method_name, type_ident, &method.doc_tags)
164                    .with_suppress_params()
165                    .with_r_name(qualified_name);
166            let mut doc_lines = method_doc.build();
167            // Add S4 method-specific alias so R CMD check finds the documented method
168            doc_lines.push(format!("#' @aliases {},{}-method", method_name, class_name));
169            lines.extend(doc_lines);
170        }
171
172        // Define generic only if it doesn't already exist IN THIS NAMESPACE.
173        // Unconditional setGeneric() replaces the generic object, clearing
174        // previously registered methods — that matters when multiple types share
175        // the same generic name (e.g., s4_get_value used by both S4TraitCounter
176        // and CounterTraitS4). The check must be a namespace-local exists():
177        // a bare isGeneric() searches S4 metadata globally, so with an installed
178        // copy of the package *attached*, load_all() skips setGeneric here while
179        // setMethod below still can't see the generic from the namespace being
180        // loaded — "no existing definition for function ..." — and
181        // isGeneric(where=) resolves the namespace to a package name, which
182        // fails mid-install (findpack). exists() is a plain env lookup and the
183        // generic function is exactly what setGeneric assigns there (#1158).
184        lines.push(format!(
185            "if (!exists(\"{0}\", where = topenv(environment()), inherits = FALSE)) methods::setGeneric(\"{0}\", function(x, ...) standardGeneric(\"{0}\"))",
186            method_name
187        ));
188
189        // Define method with @exportMethod for proper S4 dispatch (if class should be exported)
190        if should_export {
191            lines.push(format!("#' @exportMethod {}", method_name));
192        }
193
194        let strategy = crate::ReturnStrategy::for_method(method);
195        let body_lines = crate::MethodReturnBuilder::new(call)
196            .with_strategy(strategy)
197            .with_class_name(class_name.clone())
198            .build_s4_body();
199
200        let what = format!("{}.{}", method_name, class_name);
201        lines.push(format!(
202            "methods::setMethod(\"{}\", \"{}\", function({}) {{",
203            method_name, class_name, full_params
204        ));
205        ctx.emit_method_prelude(&mut lines, "  ", &what);
206        lines.extend(body_lines);
207        lines.push("})".to_string());
208        lines.push(String::new());
209    }
210
211    // Static methods as regular functions
212    for ctx in parsed_impl.static_method_contexts() {
213        lines.push(ctx.source_comment(type_ident));
214        let method_name = ctx.method.r_method_name();
215        let fn_name = format!("{}_{}", class_name, method_name);
216
217        // Skip documentation if class has @noRd
218        if !class_has_no_rd {
219            let mx_doc = ctx.match_arg_doc_placeholders();
220            let method_doc =
221                MethodDocBuilder::new(&class_name, &method_name, type_ident, &ctx.method.doc_tags)
222                    .with_r_params(&ctx.params)
223                    .with_match_arg_doc_placeholders(&mx_doc)
224                    .with_r_name(fn_name.clone());
225            lines.extend(method_doc.build());
226        }
227        // Export static methods so users can call them (if class should be exported)
228        if should_export {
229            lines.push("#' @export".to_string());
230        }
231
232        lines.push(format!("{} <- function({}) {{", fn_name, ctx.params));
233
234        ctx.emit_method_prelude(&mut lines, "  ", &fn_name);
235
236        let strategy = crate::ReturnStrategy::for_method(ctx.method);
237        let return_expr = crate::MethodReturnBuilder::new(ctx.static_call())
238            .with_strategy(strategy)
239            .with_class_name(class_name.clone())
240            .build_s4_inline();
241        lines.push(format!("  {}", return_expr));
242
243        lines.push("}".to_string());
244        lines.push(String::new());
245    }
246
247    lines.join("\n")
248}