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, ParsedMethod};
13
14/// The S4 generic an instance method dispatches through: an explicit
15/// `s4(generic = "...")` verbatim, else `s4_<R method name>`, so `r_name`,
16/// `postfix` and raw identifiers follow the usual naming rules. Shared by the
17/// generator and `check_s4_generic_collisions`, so both agree on the names.
18pub(crate) fn s4_generic_name(method: &ParsedMethod) -> String {
19    match &method.method_attrs.generic {
20        Some(generic) => generic.clone(),
21        None => ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("s4_{0}", method.r_method_name()))
    })format!("s4_{}", method.r_method_name()),
22    }
23}
24
25/// The line that defines an S4 instance method's generic before its
26/// `setMethod()`, shared by the inherent and trait generators.
27///
28/// A generic of the package's own (`explicit` false) is defined unless this
29/// namespace already has it. Unconditional `setGeneric()` would replace the
30/// generic object and clear the methods other types registered on it, and the
31/// check must be a namespace-local `exists()`: a bare `isGeneric()` searches
32/// S4 metadata globally, so with an installed copy of the package attached,
33/// `load_all()` would skip `setGeneric()` while `setMethod()` still can't see
34/// the generic from the namespace being loaded, and `isGeneric(where =)`
35/// resolves the namespace to a package name, which fails mid-install (#1158).
36///
37/// An explicit `generic = "..."` may name a generic that lives elsewhere:
38/// `methods::show` (an S4 generic `exists()` misses when `methods` is not
39/// attached), the primitive `length`, base's S3 `summary`. It is defined only
40/// when no function of that name is visible and `methods::isGeneric()` finds
41/// no generic of another package, with `receiver` as its dispatch formal;
42/// otherwise `setMethod()` attaches to the existing one instead of masking it
43/// with a `function(x, ...)` generic of this package (#1677). A generic whose
44/// `@package` is this namespace's name is an installed copy's, the #1158 case,
45/// so it is defined anyway. `environmentName()` is a plain lookup, unlike
46/// `isGeneric(where =)`. A name nothing defines is created, but gets no
47/// generated page: whether it is the package's own is only known at load time
48/// (#2039).
49pub(crate) fn s4_generic_guard(generic: &str, explicit: bool, receiver: &str) -> String {
50    let condition = if explicit {
51        ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("!exists(\"{0}\", mode = \"function\") && !(methods::isGeneric(\"{0}\") && methods::getGeneric(\"{0}\")@package != environmentName(topenv(environment())))",
                generic))
    })format!(
52            "!exists(\"{generic}\", mode = \"function\") && !(methods::isGeneric(\"{generic}\") && \
53             methods::getGeneric(\"{generic}\")@package != environmentName(topenv(environment())))"
54        )
55    } else {
56        ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("!exists(\"{0}\", where = topenv(environment()), inherits = FALSE)",
                generic))
    })format!("!exists(\"{generic}\", where = topenv(environment()), inherits = FALSE)")
57    };
58    ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("if ({0}) methods::setGeneric(\"{1}\", function({2}, ...) standardGeneric(\"{1}\"))",
                condition, generic, receiver))
    })format!(
59        "if ({condition}) methods::setGeneric(\"{generic}\", function({receiver}, ...) standardGeneric(\"{generic}\"))"
60    )
61}
62
63/// Generates the R wrapper of an S4-style class, in the parts the wrapper
64/// writer merges per type ([`super::ClassWrapper`], #1776).
65///
66/// Produces the following R code:
67/// - Class definition: `methods::setClass("<class>", slots = c(ptr = "externalptr"))`
68///   with a single `ptr` slot holding the `ExternalPtr` to the Rust struct
69/// - Constructor function: `ClassName(...)` that calls the Rust constructor
70///   and wraps the result with `methods::new("<class>", ptr = .val)`. A class
71///   without a constructor has no `ClassName()`: its factories call
72///   `methods::new()`, and a package may define its own (#2069).
73/// - S4 generics: `methods::setGeneric(...)` for each instance method, guarded by
74///   [`s4_generic_guard`]
75/// - S4 methods: `methods::setMethod("<generic>", "<class>", function(x, ...) ...)`
76///   dispatching to the Rust `.Call()` wrapper, extracting the ptr via `x@ptr`
77/// - Static methods: regular functions named `<class>_<method>(...)`
78///
79/// Roxygen2 `@exportMethod`, `@importFrom methods`, and `@slot` tags are generated
80/// as appropriate.
81///
82/// The class, its constructor and the field methods are the head; the S4
83/// methods and the static methods are the members.
84pub fn generate_s4_class(parsed_impl: &ParsedImpl) -> super::ClassWrapper {
85    use crate::r_class_formatter::{
86        ClassDocBuilder, MethodContext, MethodDocBuilder, ParsedImplExt, should_export_from_tags,
87    };
88
89    let class_name = parsed_impl.class_name();
90    let type_ident = &parsed_impl.type_ident;
91    let class_doc_tags = &parsed_impl.doc_tags;
92    // Check if class has @noRd - if so, skip method documentation and exports. A
93    // plain `noexport` (without `internal`) is folded in too — it must suppress
94    // Rd contribution entirely, matching `ClassDocBuilder::build`'s `suppress_rd`
95    // gate. `should_export` (below) already independently gates @export/@exportMethod.
96    let class_has_no_rd = crate::roxygen::has_roxygen_tag(class_doc_tags, "noRd")
97        || (parsed_impl.noexport && !parsed_impl.internal);
98    let should_export =
99        should_export_from_tags(class_doc_tags, parsed_impl.noexport || parsed_impl.internal);
100
101    let mut w = super::ClassWrapper::default();
102    let mut lines = Vec::new();
103
104    // Class definition with documentation (S4 uses setClass, no @export on class definition)
105    let has_export = crate::roxygen::has_roxygen_tag(class_doc_tags, "export");
106    let class_docs = ClassDocBuilder::new(&class_name, type_ident, class_doc_tags, "S4")
107        .with_imports("@importFrom methods setClass setGeneric setMethod new")
108        .with_export_control(parsed_impl.internal, parsed_impl.noexport);
109    lines.extend(class_docs.build());
110    w.class_docs = class_docs.docs();
111    w.mark_class_doc_lines(&mut lines, parsed_impl);
112    // Remove the @export that ClassDocBuilder adds (S4 doesn't export the class
113    // definition). Only pop when the last line actually IS the auto-added
114    // @export — with internal/noexport the builder emits no @export, and a
115    // blind pop would instead drop `@keywords internal` / `@noRd` / a user tag.
116    if !has_export
117        && lines
118            .last()
119            .is_some_and(|l| *l == super::class_default("#' @export"))
120    {
121        lines.pop();
122    }
123    if !class_has_no_rd {
124        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @slot ptr External pointer to Rust `{0}` struct",
                type_ident))
    })format!(
125            "#' @slot ptr External pointer to Rust `{}` struct",
126            type_ident
127        ));
128    }
129    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("methods::setClass(\"{0}\", slots = c(ptr = \"externalptr\"))",
                class_name))
    })format!(
130        "methods::setClass(\"{}\", slots = c(ptr = \"externalptr\"))",
131        class_name
132    ));
133    lines.push(String::new());
134
135    // Constructor function
136    if let Some(ctx) = parsed_impl.constructor_context() {
137        lines.push(ctx.source_comment(type_ident));
138        // Skip documentation if class has @noRd
139        if !class_has_no_rd {
140            // Use class name as @name to avoid duplicate "new" alias across S4 classes
141            let mx_doc = ctx.choice_param_docs();
142            let method_doc =
143                MethodDocBuilder::new(&class_name, "new", type_ident, &ctx.method.doc_tags)
144                    .with_source_method(&ctx.method.ident)
145                    .with_r_params(&ctx.params)
146                    .with_choice_param_docs(&mx_doc)
147                    .with_r_name(class_name.clone());
148            lines.extend(method_doc.build());
149        }
150        // Export the constructor function so users can create instances (if class should be exported)
151        if should_export {
152            lines.push("#' @export".to_string());
153        }
154
155        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} <- function({1}) {{",
                class_name, ctx.params))
    })format!("{} <- function({}) {{", class_name, ctx.params));
156        for check in ctx.precondition_checks() {
157            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  {0}", check))
    })format!("  {}", check));
158        }
159        // Inject match.arg validation for match_arg/choices params
160        for line in ctx.match_arg_prelude() {
161            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  {0}", line))
    })format!("  {}", line));
162        }
163        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  .val <- {0}", ctx.static_call()))
    })format!("  .val <- {}", ctx.static_call()));
164        lines.extend(crate::method_return_builder::condition_check_lines("  "));
165        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  methods::new(\"{0}\", ptr = .val)",
                class_name))
    })format!("  methods::new(\"{}\", ptr = .val)", class_name));
166        lines.push("}".to_string());
167        lines.push(String::new());
168    }
169
170    // `$` / `$<-` field methods and `.DollarNames` of `s4(r_data_accessors)`
171    // (#1848, #1885), documented on the class page wherever the class has one
172    // (R CMD check wants an `\alias{$,Class-method}`).
173    if parsed_impl.r_data_accessors.enabled() {
174        lines.extend(super::field_syntax::s4_field_methods(
175            parsed_impl,
176            !class_has_no_rd,
177            should_export,
178        ));
179    }
180    w.head = std::mem::take(&mut lines);
181
182    // Instance methods as S4 methods
183    // Note: S4 uses empty param_defaults for method signatures (different from other systems)
184    for method in parsed_impl.instance_methods() {
185        let start = method.ident.span().start();
186        let method_name = s4_generic_name(method);
187        w.member_names.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("the S4 method of `{0}` for `{1}`",
                method_name, class_name))
    })format!(
188            "the S4 method of `{method_name}` for `{class_name}`"
189        ));
190
191        // Emit the generic-doc marker BEFORE the source comment so the write-time
192        // pass can place the standalone Rd page before the method block.  This
193        // ensures the synthesised doc block (ending with NULL) is separated from
194        // the method's own roxygen comment by the `# source` line.
195        if !class_has_no_rd && method.method_attrs.generic.is_none() {
196            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!(".__MX_GENERIC_DOC__(kind=\"S4\", generic=\"{0}\", class=\"{1}\", export={2})",
                method_name, class_name, should_export))
    })format!(
197                ".__MX_GENERIC_DOC__(kind=\"S4\", generic=\"{method_name}\", class=\"{class_name}\", export={should_export})"
198            ));
199        }
200
201        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("# {0}::{1} ({2}:{3})", type_ident,
                method.ident, start.line, start.column + 1))
    })format!(
202            "# {}::{} ({}:{})",
203            type_ident,
204            method.ident,
205            start.line,
206            start.column + 1,
207        ));
208        // Build a MethodContext so S4 methods participate in the shared
209        // match_arg prelude + formal-default machinery (#209). The ctx's
210        // `params`/`instance_formals` carry the `c("a", "b")` default for
211        // match_arg'd params, and `match_arg_prelude()` emits the
212        // `base::match.arg()` validation block injected below.
213        let ctx = MethodContext::new(method, type_ident)
214            .with_impl_preconditions(parsed_impl.preconditions);
215        let receiver = method.method_attrs.receiver.as_deref().unwrap_or("x");
216        let call = ctx.instance_call(&::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}@ptr", receiver))
    })format!("{receiver}@ptr"));
217        let full_params =
218            ctx.instance_formals_with_receiver(receiver, !method.method_attrs.no_dispatch_dots);
219
220        // Documentation for the generic - skip if class has @noRd
221        // Use class-qualified @name to avoid duplicate \alias{generic} warnings
222        // when multiple S4 classes share the same generic (e.g., s4_get_value on
223        // both S4TraitCounter and CounterTraitS4). The @exportMethod directive
224        // (added separately) correctly exports the bare generic name.
225        if !class_has_no_rd {
226            let qualified_name = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}-{1}", class_name, method_name))
    })format!("{}-{}", class_name, method_name);
227            let method_doc =
228                MethodDocBuilder::new(&class_name, &method_name, type_ident, &method.doc_tags)
229                    .with_source_method(&method.ident)
230                    .with_suppress_params()
231                    .with_r_name(qualified_name);
232            let mut doc_lines = method_doc.build();
233            // Add S4 method-specific alias so R CMD check finds the documented method
234            doc_lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @aliases {0},{1}-method",
                method_name, class_name))
    })format!("#' @aliases {},{}-method", method_name, class_name));
235            lines.extend(doc_lines);
236        }
237
238        // Define the generic unless it exists. Several types can share one
239        // (s4_get_value on both S4TraitCounter and CounterTraitS4), and an
240        // explicit `generic` may be another package's (`show`).
241        lines.push(s4_generic_guard(
242            &method_name,
243            method.method_attrs.generic.is_some(),
244            receiver,
245        ));
246
247        // Define method with @exportMethod for proper S4 dispatch (if class should be exported)
248        if should_export {
249            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @exportMethod {0}",
                method_name))
    })format!("#' @exportMethod {}", method_name));
250        }
251
252        let strategy = crate::ReturnStrategy::for_method(method);
253        let body_lines = crate::MethodReturnBuilder::new(call)
254            .with_strategy(strategy)
255            .with_chain_var(receiver.to_string())
256            .with_invisible(method.is_invisible())
257            .with_class_name(class_name.clone())
258            .with_return_class_from_method(method, &type_ident.to_string())
259            .build_s4_body();
260
261        let what = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}.{1}", method_name, class_name))
    })format!("{}.{}", method_name, class_name);
262        let signature = if method.method_attrs.receiver.is_some() {
263            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("c({0} = \"{1}\")", receiver,
                class_name))
    })format!("c({receiver} = \"{class_name}\")")
264        } else {
265            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("\"{0}\"", class_name))
    })format!("\"{class_name}\"")
266        };
267        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("methods::setMethod(\"{0}\", {1}, function({2}) {{",
                method_name, signature, full_params))
    })format!(
268            "methods::setMethod(\"{method_name}\", {signature}, function({full_params}) {{"
269        ));
270        ctx.emit_method_prelude(&mut lines, "  ", &what, &full_params);
271        lines.extend(body_lines);
272        lines.push("})".to_string());
273        lines.push(String::new());
274    }
275
276    // Static methods as regular functions
277    for ctx in parsed_impl.static_method_contexts() {
278        lines.push(ctx.source_comment(type_ident));
279        let method_name = ctx.method.r_method_name();
280        let fn_name = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}_{1}", class_name, method_name))
    })format!("{}_{}", class_name, method_name);
281
282        // Skip documentation if class has @noRd
283        if !class_has_no_rd {
284            let mx_doc = ctx.choice_param_docs();
285            let doc_tags = parsed_impl.static_doc_tags(
286                ctx.method,
287                &::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}()", fn_name))
    })format!("{fn_name}()"),
288                &::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}()", class_name))
    })format!("{class_name}()"),
289                true,
290            );
291            let method_doc =
292                MethodDocBuilder::new(&class_name, &method_name, type_ident, &doc_tags)
293                    .with_source_method(&ctx.method.ident)
294                    .with_r_params(&ctx.params)
295                    .with_choice_param_docs(&mx_doc)
296                    .with_r_name(fn_name.clone());
297            lines.extend(method_doc.build());
298        }
299        // Export static methods so users can call them (if class should be exported)
300        if should_export {
301            lines.push("#' @export".to_string());
302        }
303
304        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} <- function({1}) {{",
                crate::naming::r_def_name(&fn_name), ctx.params))
    })format!(
305            "{} <- function({}) {{",
306            crate::naming::r_def_name(&fn_name),
307            ctx.params
308        ));
309
310        ctx.emit_method_prelude(&mut lines, "  ", &fn_name, &ctx.params);
311
312        let strategy = crate::ReturnStrategy::for_method(ctx.method);
313        let return_expr = crate::MethodReturnBuilder::new(ctx.static_call())
314            .with_strategy(strategy)
315            .with_invisible(ctx.method.is_invisible())
316            .with_class_name(class_name.clone())
317            .with_return_class_from_method(ctx.method, &type_ident.to_string())
318            .build_s4_inline();
319        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  {0}", return_expr))
    })format!("  {}", return_expr));
320
321        lines.push("}".to_string());
322        lines.push(String::new());
323    }
324
325    w.members = lines;
326    w
327}
328
329/// The S4 class as one string: [`generate_s4_class`], rendered.
330#[cfg(test)]
331pub fn generate_s4_r_wrapper(parsed_impl: &ParsedImpl) -> String {
332    generate_s4_class(parsed_impl).render()
333}