Skip to main content

miniextendr_macros/miniextendr_impl/
s3_class.rs

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