Skip to main content

miniextendr_macros/miniextendr_impl/
s3_class.rs

1//! S3-class R wrapper generator.
2//!
3//! Generates **lightweight, single-dispatch** S3 generics with
4//! `<generic>.<class>` methods. Method dispatch is driven by the `class()`
5//! attribute vector — first match wins — so inheritance is "string-prefix"
6//! based and cheap. No formal slot validation, no multi-dispatch (vctrs-style
7//! double-dispatch is supported via `#[miniextendr(s3(generic = "...", class = "..."))]` for
8//! `vec_ptype2.a.b` patterns). Pick S3 for tidyverse interop and for
9//! extending existing base generics (`print`, `format`, `summary`); use S4/S7
10//! when you need validation or formal hierarchies.
11
12use super::ParsedImpl;
13
14/// Generates the R wrapper of an S3-style class, in the parts the wrapper
15/// writer merges per type ([`super::ClassWrapper`], #1776).
16///
17/// Produces the following R code:
18/// - Constructor: `new_<class>(...)` function that calls the Rust constructor
19///   and wraps the result with `structure(.val, class = "<class>")`; none for
20///   a class without a constructor (see [`no_constructor_head`])
21/// - S3 generics: for each instance method, a `UseMethod()` generic is created
22///   (unless overriding an existing generic via `#[miniextendr(generic = "...")]`)
23/// - S3 methods: `<generic>.<class>` functions dispatching to the Rust `.Call()` wrapper,
24///   with the ExternalPtr extracted from `x`
25/// - Static methods: regular functions named `<class>_<method>(...)`
26/// - Class environment: `ClassName <- new.env(parent = emptyenv())` for `Class$new()`
27///   syntax and trait namespaces, when the impl has a constructor or the class
28///   is named as its Rust type (#1918)
29///
30/// Custom double-dispatch patterns (e.g., `vec_ptype2.a.b`) are supported via
31/// `#[miniextendr(generic = "...", class = "...")]` attributes.
32///
33/// The constructor and the field methods are the head; the S3 methods and the
34/// static methods are the members; the class environment is the tail.
35pub fn generate_s3_class(parsed_impl: &ParsedImpl) -> super::ClassWrapper {
36    use crate::r_class_formatter::{
37        ClassDocBuilder, MethodDocBuilder, ParsedImplExt, emit_s3_generic_guard,
38        should_export_from_tags,
39    };
40
41    let class_name = parsed_impl.class_name();
42    let type_ident = &parsed_impl.type_ident;
43    // S3 convention: lowercase constructor name
44    let ctor_name = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("new_{0}",
                class_name.to_lowercase()))
    })format!("new_{}", class_name.to_lowercase());
45    let class_doc_tags = &parsed_impl.doc_tags;
46    // A plain `noexport` (without `internal`) is folded into the @noRd gate too —
47    // it must suppress Rd contribution entirely, matching `ClassDocBuilder::build`'s
48    // `suppress_rd` gate. `@method`/`@export` tags stay preserved for S3 dispatch
49    // registration by the existing @noRd-handling branches below.
50    let class_has_no_rd = crate::roxygen::has_roxygen_tag(class_doc_tags, "noRd")
51        || (parsed_impl.noexport && !parsed_impl.internal);
52    // Generic export (NAMESPACE `export(generic_name)`): suppressed by
53    // @noRd / internal / noexport. An internal class doesn't pollute the
54    // package's user-facing surface with a bare generic.
55    let should_export =
56        should_export_from_tags(class_doc_tags, parsed_impl.noexport || parsed_impl.internal);
57    // Method @export (NAMESPACE `S3method(generic, Class)`): only suppressed
58    // by `noexport`. `internal` should keep S3method registration so dispatch
59    // still works for instances of the class — without it, callers (including
60    // the package's own tests) couldn't dispatch on the type at all. See #431.
61    let should_register_s3method = !parsed_impl.noexport;
62
63    let mut w = super::ClassWrapper::default();
64    let mut lines = Vec::new();
65    // The class block is the constructor's; a block without one still
66    // documents the class with its impl docs.
67    crate::roxygen::push_roxygen_tags(&mut w.class_docs, class_doc_tags);
68
69    // Constructor with combined class and constructor documentation
70    if let Some(ctx) = parsed_impl.constructor_context() {
71        w.member_names.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("`{0}$new`", class_name))
    })format!("`{class_name}$new`"));
72        lines.push(ctx.source_comment(type_ident));
73        let mut ctor_doc_tags = Vec::new();
74        ctor_doc_tags.extend(class_doc_tags.iter().cloned());
75        ctor_doc_tags.extend(ctx.method.doc_tags.iter().cloned());
76        let ctor_choice_docs = ctx.choice_param_docs();
77
78        let class_docs = ClassDocBuilder::new(&class_name, type_ident, &ctor_doc_tags, "S3")
79            .with_ctor_params(&ctx.params, &ctor_choice_docs)
80            .with_export_control(parsed_impl.internal, parsed_impl.noexport);
81        lines.extend(class_docs.build());
82        w.class_docs = class_docs.docs();
83        w.mark_class_doc_lines(&mut lines, parsed_impl);
84        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} <- function({1}) {{",
                ctor_name, ctx.params))
    })format!("{} <- function({}) {{", ctor_name, ctx.params));
85        for check in ctx.precondition_checks() {
86            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  {0}", check))
    })format!("  {}", check));
87        }
88        // Inject match.arg validation for match_arg/choices params
89        for line in ctx.match_arg_prelude() {
90            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  {0}", line))
    })format!("  {}", line));
91        }
92        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  .val <- {0}", ctx.static_call()))
    })format!("  .val <- {}", ctx.static_call()));
93        lines.extend(crate::method_return_builder::condition_check_lines("  "));
94        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  structure(.val, class = \"{0}\")",
                class_name))
    })format!("  structure(.val, class = \"{}\")", class_name));
95        lines.push("}".to_string());
96        lines.push(String::new());
97    } else {
98        no_constructor_head(parsed_impl, &mut w, &mut lines, "S3", None);
99    }
100
101    // The field methods of `s3(r_data_accessors)` (`$` / `[[` / `names` /
102    // `as.list` / `.DollarNames`, and the setters; #1848, #1890, #1885),
103    // ahead of the instance methods: on a shared page roxygen2 keeps the last
104    // `@param` of a name, so a method's own text for `name` or `value` wins.
105    // They are documented whenever the class is: `internal` keeps the class
106    // out of the exports, and its page (`\keyword{internal}`) still documents
107    // the registered methods, as for S4 (#1918).
108    if parsed_impl.r_data_accessors.enabled() {
109        lines.extend(super::field_syntax::s3_field_methods(
110            parsed_impl,
111            !class_has_no_rd,
112            should_register_s3method,
113        ));
114    }
115    w.head = std::mem::take(&mut lines);
116
117    // Instance methods as S3 generics + methods. Their conditions report the
118    // generic's call, resolved from the method frame on a raise (#1851).
119    for ctx in parsed_impl.instance_method_contexts() {
120        let ctx = ctx.with_call_attribution(crate::r_wrapper_builder::CallAttribution::Generic);
121        lines.push(ctx.source_comment(type_ident));
122        let qualified_generic = ctx.generic_name();
123        let (generic_package, generic_name) = crate::naming::split_generic(&qualified_generic);
124        // Use custom class suffix if provided (for double-dispatch patterns like vec_ptype2.a.b)
125        let method_class_suffix = ctx
126            .class_suffix()
127            .map(|s| s.to_string())
128            .unwrap_or_else(|| class_name.clone());
129        let s3_method_name = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}.{1}", generic_name,
                method_class_suffix))
    })format!("{}.{}", generic_name, method_class_suffix);
130        // `@export` on the method gives `S3method(generic, Class)`. A method on
131        // `pkg::generic` registers through `@exportS3Method pkg::generic Class`
132        // instead: R applies `S3method(pkg::generic, Class)` once `pkg`'s
133        // namespace loads, so `pkg` may stay in Suggests (#2154).
134        let registration = match generic_package {
135            Some(package) => {
136                ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @exportS3Method {0}::{1} {2}",
                package, generic_name, method_class_suffix))
    })format!("#' @exportS3Method {package}::{generic_name} {method_class_suffix}")
137            }
138            None => "#' @export".to_string(),
139        };
140        let receiver = ctx.method.method_attrs.receiver.as_deref().unwrap_or("x");
141        let full_params = ctx.s3_method_formals(); // x, params, ... (`value` last for `*<-`)
142
143        // Only create the S3 generic if no generic/class override was provided
144        // (custom class suffix implies using an existing generic)
145        if !ctx.has_generic_override() && !ctx.has_class_override() {
146            // Create the S3 generic (only for custom generics, not base R overrides)
147            if class_has_no_rd {
148                lines.push("#' @noRd".to_string());
149            } else {
150                // The generic block is named after the S3 method (`generic.Class`,
151                // class-qualified so classes sharing a generic don't collide on
152                // `\alias{generic}`). That is the same alias the method block
153                // below produces, so both must land on the same page: a
154                // method-level `@rdname` or `@describeIn` moves the generic block
155                // along with the method, otherwise R CMD check reports the alias
156                // as duplicated across the class page and the method's page.
157                let method_tags = &ctx.method.doc_tags;
158                let joined = crate::roxygen::joins_author_topic(method_tags, Some(&class_name));
159                if !joined {
160                    // roxygen2 keeps the first `@title` on a page. On the class
161                    // page that is the class block's, so this one is inert; on a
162                    // split page it would beat the method's structural title.
163                    // The author's topic describes the method itself, so the
164                    // structural description would only add a stray paragraph.
165                    lines.push(super::member_default(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @title S3 generic for `{0}`",
                generic_name))
    })format!(
166                        "#' @title S3 generic for `{}`",
167                        generic_name
168                    )));
169                    lines.push(super::member_default(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @description S3 generic for `{0}`",
                generic_name))
    })format!(
170                        "#' @description S3 generic for `{}`",
171                        generic_name
172                    )));
173                }
174                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @name {0}.{1}", generic_name,
                class_name))
    })format!("#' @name {}.{}", generic_name, class_name));
175                super::push_member_rdname(&mut lines, method_tags, &class_name);
176                if !joined {
177                    // On the author's topic its own block documents `x` and
178                    // `...`; a generated line would replace that text (#1590).
179                    lines.push(super::member_default("#' @param x An object".to_string()));
180                    lines.push(super::member_default(
181                        "#' @param ... Additional arguments passed to methods".to_string(),
182                    ));
183                }
184                crate::roxygen::push_order_after_topic_blocks(&mut lines, method_tags, &class_name);
185                lines.extend(crate::roxygen::method_source_tag(
186                    type_ident,
187                    &ctx.method.ident,
188                ));
189                if should_export {
190                    // Explicit name on @export: the generic is wrapped in
191                    // `if (!exists(...))`, which roxygen2 can't introspect, and the
192                    // @name tag above is the class-qualified form (to dedupe aliases
193                    // when several classes share a generic). Without an explicit
194                    // target on @export, roxygen2 attaches the export to the next
195                    // parseable function (the S3 method), producing a bogus
196                    // `export(generic.Class)` instead of `export(generic)`.
197                    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @export {0}", generic_name))
    })format!("#' @export {}", generic_name));
198                }
199            }
200            lines.push(emit_s3_generic_guard(generic_name));
201            lines.push(String::new());
202        }
203
204        // Then create the S3 method
205        if class_has_no_rd {
206            // @noRd class: minimal roxygen — just @method (+ @export for NAMESPACE
207            // dispatch registration, gated by `should_register_s3method` so a
208            // `noexport`-driven suppression — folded into `class_has_no_rd` above —
209            // also drops S3 dispatch, not just Rd docs; a plain user `@noRd` with
210            // no `noexport` attr still registers dispatch as before, since
211            // `should_register_s3method` is `true` whenever `noexport` isn't set).
212            // @export on S3 methods produces S3method() in NAMESPACE (not export()).
213            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @method {0} {1}", generic_name,
                method_class_suffix))
    })format!("#' @method {generic_name} {method_class_suffix}"));
214            if should_register_s3method {
215                lines.push(registration);
216            }
217        } else {
218            let mx_doc = ctx.choice_param_docs();
219            let method_doc =
220                MethodDocBuilder::new(&class_name, generic_name, type_ident, &ctx.method.doc_tags)
221                    .with_source_method(&ctx.method.ident)
222                    .with_r_params(&ctx.params)
223                    .with_choice_param_docs(&mx_doc)
224                    .with_r_name(s3_method_name.clone());
225            lines.extend(method_doc.build());
226            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @method {0} {1}", generic_name,
                method_class_suffix))
    })format!("#' @method {generic_name} {method_class_suffix}"));
227            // roxygen2 can't parse generic blocks wrapped in if (!exists(...)),
228            // so @param x/@param ... must also appear on the method block. On
229            // an author topic (method-level `@rdname` / `@describeIn`) the
230            // topic's own block documents them instead: roxygen2 keeps the
231            // later `@param` of a name, and this block sorts last (#1590).
232            if !crate::roxygen::joins_author_topic(&ctx.method.doc_tags, Some(&class_name)) {
233                lines.push(super::member_default(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @param {0} An object.",
                receiver))
    })format!(
234                    "#' @param {receiver} An object."
235                )));
236                if !ctx.method.method_attrs.no_dispatch_dots || ctx.method.has_dots {
237                    lines.push(super::member_default(
238                        "#' @param ... Additional arguments.".to_string(),
239                    ));
240                }
241            }
242            if should_register_s3method {
243                lines.push(registration);
244            }
245        }
246        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} <- function({1}) {{",
                crate::naming::r_def_name(&s3_method_name), full_params))
    })format!(
247            "{} <- function({}) {{",
248            crate::naming::r_def_name(&s3_method_name),
249            full_params
250        ));
251
252        ctx.emit_method_prelude(&mut lines, "  ", &s3_method_name, &full_params);
253
254        let call = ctx.instance_call(receiver);
255        let strategy = crate::ReturnStrategy::for_method(ctx.method);
256        let return_builder = crate::MethodReturnBuilder::new(call)
257            .with_raise_default(ctx.call_attribution.raise_default())
258            .with_strategy(strategy)
259            .with_invisible(ctx.method.is_invisible())
260            .with_class_name(class_name.clone())
261            .with_return_class_from_method(ctx.method, &type_ident.to_string())
262            .with_chain_var(receiver.to_string());
263        lines.extend(return_builder.build_s3_body());
264
265        lines.push("}".to_string());
266        lines.push(String::new());
267    }
268
269    // Static methods as regular functions
270    for ctx in parsed_impl.static_method_contexts() {
271        lines.push(ctx.source_comment(type_ident));
272        // Static methods get a prefix to avoid naming conflicts
273        let method_name = ctx.method.r_method_name();
274        let fn_name = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}_{1}",
                class_name.to_lowercase(), method_name))
    })format!("{}_{}", class_name.to_lowercase(), method_name);
275
276        let mx_doc = ctx.choice_param_docs();
277        let doc_tags = parsed_impl.static_doc_tags(
278            ctx.method,
279            &::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}()", fn_name))
    })format!("{fn_name}()"),
280            &::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}()", ctor_name))
    })format!("{ctor_name}()"),
281            true,
282        );
283        let method_doc = MethodDocBuilder::new(&class_name, &method_name, type_ident, &doc_tags)
284            .with_source_method(&ctx.method.ident)
285            .with_r_params(&ctx.params)
286            .with_choice_param_docs(&mx_doc)
287            .with_r_name(fn_name.clone())
288            .with_class_no_rd(class_has_no_rd);
289        lines.extend(method_doc.build());
290        // Export static methods so users can call them
291        if !class_has_no_rd {
292            lines.push("#' @export".to_string());
293        }
294
295        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} <- function({1}) {{",
                crate::naming::r_def_name(&fn_name), ctx.params))
    })format!(
296            "{} <- function({}) {{",
297            crate::naming::r_def_name(&fn_name),
298            ctx.params
299        ));
300
301        ctx.emit_method_prelude(&mut lines, "  ", &fn_name, &ctx.params);
302
303        let strategy = crate::ReturnStrategy::for_method(ctx.method);
304        let return_builder = crate::MethodReturnBuilder::new(ctx.static_call())
305            .with_strategy(strategy)
306            .with_invisible(ctx.method.is_invisible())
307            .with_class_name(class_name.clone())
308            .with_return_class_from_method(ctx.method, &type_ident.to_string());
309        lines.extend(return_builder.build_s3_body());
310
311        lines.push("}".to_string());
312        lines.push(String::new());
313    }
314    w.members = std::mem::take(&mut lines);
315
316    // The class environment holds `Class$new()` and the trait namespaces
317    // (`Type$Trait`, named by the Rust type, for a trait's static methods and
318    // consts; static methods of the impl are plain functions). Emit it only
319    // when one of them can land in it: an impl with a constructor, or a class
320    // named as its Rust type. An impl that only adds the field readers to a
321    // class the package names and builds itself gets none, so no second
322    // binding of a same-named constructor, export or `\alias` (#1918).
323    let has_class_env = parsed_impl.constructor_context().is_some() || *type_ident == class_name;
324    let export_line = if should_export { "#' @export\n" } else { "" };
325    if has_class_env {
326        if class_has_no_rd {
327            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @noRd\n{0} <- new.env(parent = emptyenv())",
                class_name))
    })format!(
328                "#' @noRd
329{} <- new.env(parent = emptyenv())",
330                class_name
331            ));
332        } else {
333            // On the impl's `@rdname` topic when it names one, after the topic's
334            // own block (#1900).
335            let topic = super::field_syntax::topic(parsed_impl);
336            let order = if topic == class_name {
337                String::new()
338            } else {
339                ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' {0}\n",
                crate::roxygen::ORDER_AFTER_TOPIC_BLOCKS))
    })format!("#' {}\n", crate::roxygen::ORDER_AFTER_TOPIC_BLOCKS)
340            };
341            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @rdname {0}\n{1}{2}{3} <- new.env(parent = emptyenv())",
                topic, order, export_line, class_name))
    })format!(
342                "#' @rdname {topic}\n{order}{export_line}{class_name} <- new.env(parent = emptyenv())"
343            ));
344        }
345        lines.push(String::new());
346    }
347
348    // Add $new binding to class environment (for Class$new() syntax)
349    if parsed_impl.constructor_context().is_some() {
350        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}$new <- {1}", class_name,
                ctor_name))
    })format!("{}$new <- {}", class_name, ctor_name));
351        lines.push(String::new());
352    }
353
354    w.tail = lines;
355    w
356}
357
358/// The S3 class as one string: [`generate_s3_class`], rendered.
359#[cfg(test)]
360pub fn generate_s3_r_wrapper(parsed_impl: &ParsedImpl) -> String {
361    generate_s3_class(parsed_impl).render()
362}
363
364/// The head of an S3 or vctrs class without a constructor: `kind` is the
365/// class system in its title, `imports` its roxygen imports. Neither gets a
366/// `new_<class>()`: a package that builds its objects in R may write its own
367/// (#2069).
368///
369/// - A class named as its Rust type has its class block on `NULL`, without
370///   an `@export` of the class name: S3's class environment exports it, and
371///   vctrs has no object of that name.
372/// - A class the package names and builds itself (`class = "..."`) has no
373///   class environment either (#1918). Its roxygen block only carries the
374///   impl docs ([`super::docs_only_class_block`]).
375pub(super) fn no_constructor_head(
376    parsed_impl: &ParsedImpl,
377    w: &mut super::ClassWrapper,
378    lines: &mut Vec<String>,
379    kind: &'static str,
380    imports: Option<&str>,
381) {
382    let class_name = parsed_impl.class_name();
383    let type_ident = &parsed_impl.type_ident;
384    if *type_ident != class_name {
385        if let Some(imports) = imports {
386            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' {0}", imports))
    })format!("#' {imports}"));
387            lines.push("NULL".to_string());
388            lines.push(String::new());
389        }
390        lines.extend(super::docs_only_class_block(parsed_impl, kind));
391        w.mark_class_doc_lines(lines, parsed_impl);
392        lines.push(String::new());
393        return;
394    }
395    let mut class_docs = crate::r_class_formatter::ClassDocBuilder::new(
396        &class_name,
397        type_ident,
398        &parsed_impl.doc_tags,
399        kind,
400    )
401    .with_export_control(parsed_impl.internal, parsed_impl.noexport);
402    if let Some(imports) = imports {
403        class_docs = class_docs.with_imports(imports);
404    }
405    let export = super::class_default("#' @export");
406    lines.extend(
407        class_docs
408            .build()
409            .into_iter()
410            .filter(|line| *line != export),
411    );
412    w.class_docs = class_docs.docs();
413    lines.push("NULL".to_string());
414    w.mark_class_doc_lines(lines, parsed_impl);
415    lines.push(String::new());
416}