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}