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(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 complete R wrapper string for an S3-style class.
15///
16/// Produces the following R code:
17/// - Constructor: `new_<class>(...)` function that calls the Rust `new` constructor
18///   and wraps the result with `structure(.val, class = "<class>")`
19/// - S3 generics: for each instance method, a `UseMethod()` generic is created
20///   (unless overriding an existing generic via `#[miniextendr(generic = "...")]`)
21/// - S3 methods: `<generic>.<class>` functions dispatching to the Rust `.Call()` wrapper,
22///   with the ExternalPtr extracted from `x`
23/// - Static methods: regular functions named `<class>_<method>(...)`
24/// - Class environment: `ClassName <- new.env(parent = emptyenv())` for `Class$new()`
25///   syntax and trait namespace compatibility
26///
27/// Custom double-dispatch patterns (e.g., `vec_ptype2.a.b`) are supported via
28/// `#[miniextendr(generic = "...", class = "...")]` attributes.
29pub fn generate_s3_r_wrapper(parsed_impl: &ParsedImpl) -> String {
30    use crate::r_class_formatter::{
31        ClassDocBuilder, MethodDocBuilder, ParsedImplExt, emit_s3_generic_guard,
32        should_export_from_tags,
33    };
34
35    let class_name = parsed_impl.class_name();
36    let type_ident = &parsed_impl.type_ident;
37    // S3 convention: lowercase constructor name
38    let ctor_name = format!("new_{}", class_name.to_lowercase());
39    let class_doc_tags = &parsed_impl.doc_tags;
40    // A plain `noexport` (without `internal`) is folded into the @noRd gate too —
41    // it must suppress Rd contribution entirely, matching `ClassDocBuilder::build`'s
42    // `suppress_rd` gate. `@method`/`@export` tags stay preserved for S3 dispatch
43    // registration by the existing @noRd-handling branches below.
44    let class_has_no_rd = crate::roxygen::has_roxygen_tag(class_doc_tags, "noRd")
45        || (parsed_impl.noexport && !parsed_impl.internal);
46    // Generic export (NAMESPACE `export(generic_name)`): suppressed by
47    // @noRd / internal / noexport. An internal class doesn't pollute the
48    // package's user-facing surface with a bare generic.
49    let should_export =
50        should_export_from_tags(class_doc_tags, parsed_impl.noexport || parsed_impl.internal);
51    // Method @export (NAMESPACE `S3method(generic, Class)`): only suppressed
52    // by `noexport`. `internal` should keep S3method registration so dispatch
53    // still works for instances of the class — without it, callers (including
54    // the package's own tests) couldn't dispatch on the type at all. See #431.
55    let should_register_s3method = !parsed_impl.noexport;
56
57    let mut lines = Vec::new();
58
59    // Constructor with combined class and constructor documentation
60    if let Some(ctx) = parsed_impl.constructor_context() {
61        lines.push(ctx.source_comment(type_ident));
62        let mut ctor_doc_tags = Vec::new();
63        ctor_doc_tags.extend(class_doc_tags.iter().cloned());
64        ctor_doc_tags.extend(ctx.method.doc_tags.iter().cloned());
65
66        lines.extend(
67            ClassDocBuilder::new(&class_name, type_ident, &ctor_doc_tags, "S3")
68                .with_export_control(parsed_impl.internal, parsed_impl.noexport)
69                .build(),
70        );
71        // Inject lifecycle imports from methods into class-level roxygen block
72        if let Some(lc_import) = crate::lifecycle::collect_lifecycle_imports(
73            parsed_impl
74                .methods
75                .iter()
76                .filter_map(|m| m.method_attrs.lifecycle.as_ref()),
77        ) {
78            let insert_pos = lines.len().saturating_sub(1);
79            lines.insert(insert_pos, format!("#' {}", lc_import));
80        }
81        lines.push(format!("{} <- function({}) {{", ctor_name, ctx.params));
82        for check in ctx.precondition_checks() {
83            lines.push(format!("  {}", check));
84        }
85        // Inject match.arg validation for match_arg/choices params
86        for line in ctx.match_arg_prelude() {
87            lines.push(format!("  {}", line));
88        }
89        lines.push(format!("  .val <- {}", ctx.static_call()));
90        lines.extend(crate::method_return_builder::condition_check_lines("  "));
91        lines.push(format!("  structure(.val, class = \"{}\")", class_name));
92        lines.push("}".to_string());
93        lines.push(String::new());
94    }
95
96    // Instance methods as S3 generics + methods
97    for ctx in parsed_impl.instance_method_contexts() {
98        lines.push(ctx.source_comment(type_ident));
99        let generic_name = ctx.generic_name();
100        // Use custom class suffix if provided (for double-dispatch patterns like vec_ptype2.a.b)
101        let method_class_suffix = ctx
102            .class_suffix()
103            .map(|s| s.to_string())
104            .unwrap_or_else(|| class_name.clone());
105        let s3_method_name = format!("{}.{}", generic_name, method_class_suffix);
106        let full_params = ctx.instance_formals(true); // adds x, ..., params
107
108        // Only create the S3 generic if no generic/class override was provided
109        // (custom class suffix implies using an existing generic)
110        if !ctx.has_generic_override() && !ctx.has_class_override() {
111            // Create the S3 generic (only for custom generics, not base R overrides)
112            if class_has_no_rd {
113                lines.push("#' @noRd".to_string());
114            } else {
115                lines.push(format!("#' @title S3 generic for `{}`", generic_name));
116                lines.push(format!("#' @description S3 generic for `{}`", generic_name));
117                // Use class-qualified name to avoid duplicate alias when multiple
118                // classes define the same S3 generic (e.g., get_value).
119                lines.push(format!("#' @name {}.{}", generic_name, class_name));
120                lines.push(format!("#' @rdname {}", class_name));
121                lines.push("#' @param x An object".to_string());
122                lines.push("#' @param ... Additional arguments passed to methods".to_string());
123                lines.push(crate::roxygen::method_source_tag(
124                    type_ident,
125                    &ctx.method.ident,
126                ));
127                if should_export {
128                    // Explicit name on @export: the generic is wrapped in
129                    // `if (!exists(...))`, which roxygen2 can't introspect, and the
130                    // @name tag above is the class-qualified form (to dedupe aliases
131                    // when several classes share a generic). Without an explicit
132                    // target on @export, roxygen2 attaches the export to the next
133                    // parseable function (the S3 method), producing a bogus
134                    // `export(generic.Class)` instead of `export(generic)`.
135                    lines.push(format!("#' @export {}", generic_name));
136                }
137            }
138            lines.push(emit_s3_generic_guard(&generic_name));
139            lines.push(String::new());
140        }
141
142        // Then create the S3 method
143        if class_has_no_rd {
144            // @noRd class: minimal roxygen — just @method (+ @export for NAMESPACE
145            // dispatch registration, gated by `should_register_s3method` so a
146            // `noexport`-driven suppression — folded into `class_has_no_rd` above —
147            // also drops S3 dispatch, not just Rd docs; a plain user `@noRd` with
148            // no `noexport` attr still registers dispatch as before, since
149            // `should_register_s3method` is `true` whenever `noexport` isn't set).
150            // @export on S3 methods produces S3method() in NAMESPACE (not export()).
151            lines.push(format!("#' @method {} {}", generic_name, class_name));
152            if should_register_s3method {
153                lines.push("#' @export".to_string());
154            }
155        } else {
156            let qualified_name = format!("{}.{}", generic_name, class_name);
157            let mx_doc = ctx.match_arg_doc_placeholders();
158            let method_doc =
159                MethodDocBuilder::new(&class_name, &generic_name, type_ident, &ctx.method.doc_tags)
160                    .with_r_params(&ctx.params)
161                    .with_match_arg_doc_placeholders(&mx_doc)
162                    .with_r_name(qualified_name);
163            lines.extend(method_doc.build());
164            lines.push(format!("#' @method {} {}", generic_name, class_name));
165            // roxygen2 can't parse generic blocks wrapped in if (!exists(...)),
166            // so @param x/@param ... must also appear on the method block
167            lines.push("#' @param x An object.".to_string());
168            lines.push("#' @param ... Additional arguments.".to_string());
169            if should_register_s3method {
170                lines.push("#' @export".to_string());
171            }
172        }
173        lines.push(format!(
174            "{} <- function({}) {{",
175            s3_method_name, full_params
176        ));
177
178        let what = format!("{}.{}", generic_name, class_name);
179        ctx.emit_method_prelude(&mut lines, "  ", &what);
180
181        let call = ctx.instance_call("x");
182        let strategy = crate::ReturnStrategy::for_method(ctx.method);
183        let return_builder = crate::MethodReturnBuilder::new(call)
184            .with_strategy(strategy)
185            .with_class_name(class_name.clone())
186            .with_chain_var("x".to_string());
187        lines.extend(return_builder.build_s3_body());
188
189        lines.push("}".to_string());
190        lines.push(String::new());
191    }
192
193    // Static methods as regular functions
194    for ctx in parsed_impl.static_method_contexts() {
195        lines.push(ctx.source_comment(type_ident));
196        // Static methods get a prefix to avoid naming conflicts
197        let method_name = ctx.method.r_method_name();
198        let fn_name = format!("{}_{}", class_name.to_lowercase(), method_name);
199
200        let mx_doc = ctx.match_arg_doc_placeholders();
201        let method_doc =
202            MethodDocBuilder::new(&class_name, &method_name, type_ident, &ctx.method.doc_tags)
203                .with_r_params(&ctx.params)
204                .with_match_arg_doc_placeholders(&mx_doc)
205                .with_r_name(fn_name.clone())
206                .with_class_no_rd(class_has_no_rd);
207        lines.extend(method_doc.build());
208        // Export static methods so users can call them
209        if !class_has_no_rd {
210            lines.push("#' @export".to_string());
211        }
212
213        lines.push(format!("{} <- function({}) {{", fn_name, ctx.params));
214
215        ctx.emit_method_prelude(&mut lines, "  ", &fn_name);
216
217        let strategy = crate::ReturnStrategy::for_method(ctx.method);
218        let return_builder = crate::MethodReturnBuilder::new(ctx.static_call())
219            .with_strategy(strategy)
220            .with_class_name(class_name.clone());
221        lines.extend(return_builder.build_s3_body());
222
223        lines.push("}".to_string());
224        lines.push(String::new());
225    }
226
227    // Create class environment for static methods and trait namespace compatibility
228    // Check if class should be exported (reuse should_export already computed above)
229    let export_line = if should_export { "#' @export\n" } else { "" };
230    if class_has_no_rd {
231        lines.push(format!(
232            "#' @noRd
233{} <- new.env(parent = emptyenv())",
234            class_name
235        ));
236    } else {
237        lines.push(format!(
238            "#' @rdname {}
239{}{} <- new.env(parent = emptyenv())",
240            class_name, export_line, class_name
241        ));
242    }
243    lines.push(String::new());
244
245    // Add $new binding to class environment (for Class$new() syntax)
246    if parsed_impl.constructor_context().is_some() {
247        lines.push(format!("{}$new <- {}", class_name, ctor_name));
248        lines.push(String::new());
249    }
250
251    lines.join("\n")
252}