Skip to main content

miniextendr_macros/miniextendr_impl/
r6_class.rs

1//! R6-class R wrapper generator.
2
3use super::ParsedImpl;
4use crate::r_class_formatter::class_ref_or_verbatim;
5
6/// Generates the complete R wrapper string for an R6-style class.
7///
8/// Produces an `R6::R6Class(...)` definition that includes:
9/// - `initialize` method: calls the Rust `new` constructor, or accepts a pre-made `.ptr`
10///   when static methods return `Self` (factory pattern)
11/// - Public methods: one R function per `&self`/`&mut self` instance method
12/// - Private methods: methods marked with `#[miniextendr(private)]`
13/// - Active bindings: getter/setter properties via `#[miniextendr(r6(prop = "..."))]`
14/// - Private `.ptr` field: holds the `ExternalPtr` to the Rust struct
15/// - Finalizer: optional destructor called when the R6 object is garbage-collected
16/// - Deep clone: optional custom clone logic via `#[miniextendr(r6(deep_clone))]`
17/// - Static methods: emitted as `ClassName$method_name <- function(...)` outside the class
18/// - Class options: `lock_objects`, `lock_class`, `cloneable`, `portable`, `inherit`
19///
20/// Also generates roxygen2 documentation blocks for the class, its methods,
21/// and active bindings.
22pub fn generate_r6_r_wrapper(parsed_impl: &ParsedImpl) -> String {
23    use crate::r_class_formatter::{ClassDocBuilder, MethodDocBuilder, ParsedImplExt};
24
25    let class_name = parsed_impl.class_name();
26    let type_ident = &parsed_impl.type_ident;
27    let class_doc_tags = &parsed_impl.doc_tags;
28
29    // Check if .ptr parameter will be added to initialize (for static methods returning Self)
30    let has_self_returning_methods = parsed_impl
31        .methods
32        .iter()
33        .filter(|m| m.should_include())
34        .any(|m| m.returns_self());
35
36    let mut lines = Vec::new();
37
38    // Start R6Class definition with documentation
39    lines.extend(
40        ClassDocBuilder::new(&class_name, type_ident, class_doc_tags, "R6")
41            .with_imports("@importFrom R6 R6Class")
42            .with_export_control(parsed_impl.internal, parsed_impl.noexport)
43            .build(),
44    );
45    // Inject lifecycle imports from methods into class-level roxygen block
46    if let Some(lc_import) = crate::lifecycle::collect_lifecycle_imports(
47        parsed_impl
48            .methods
49            .iter()
50            .filter_map(|m| m.method_attrs.lifecycle.as_ref()),
51    ) {
52        // Insert before @export (which is last)
53        let insert_pos = lines.len().saturating_sub(1);
54        lines.insert(insert_pos, format!("#' {}", lc_import));
55    }
56
57    // Document .ptr param if initialize will have it (for static methods returning Self)
58    if has_self_returning_methods && !crate::roxygen::has_roxygen_tag(class_doc_tags, "param .ptr")
59    {
60        // Insert before @export (which is last)
61        let insert_pos = lines.len().saturating_sub(1);
62        lines.insert(
63            insert_pos,
64            "#' @param .ptr Internal pointer (used by static methods, not for direct use)."
65                .to_string(),
66        );
67    }
68    // R6Class definition — optionally include inherit.
69    // Use a placeholder so the resolver can look up the actual R class name
70    // at cdylib write time (handles `class = "Override"` on the parent).
71    if let Some(ref parent) = parsed_impl.r6_inherit {
72        let parent_ref = class_ref_or_verbatim(parent);
73        lines.push(format!(
74            "{} <- R6::R6Class(\"{}\", inherit = {},",
75            class_name, class_name, parent_ref
76        ));
77    } else {
78        lines.push(format!("{} <- R6::R6Class(\"{}\",", class_name, class_name));
79    }
80
81    // Portable flag (only emit if explicitly set to FALSE, since TRUE is default)
82    if parsed_impl.r6_portable == Some(false) {
83        lines.push("  portable = FALSE,".to_string());
84    }
85
86    // Public list
87    lines.push("  public = list(".to_string());
88
89    // Public instance methods (collect first to know if we need trailing comma on initialize)
90    let public_method_contexts: Vec<_> = parsed_impl.public_instance_method_contexts().collect();
91    let has_public_methods = !public_method_contexts.is_empty();
92
93    // Constructor (initialize) - accepts either normal params or a pre-made .ptr.
94    // If there's no explicit `new()` but there are factory methods returning Self,
95    // generate a minimal initialize(.ptr) so factories can call $new(.ptr = val).
96    if let Some(ctx) = parsed_impl.constructor_context() {
97        lines.push(format!("    {}", ctx.source_comment(type_ident)));
98        // Add inline roxygen documentation for initialize method
99        // Note: @title is replaced with @description for R6 inline docs (roxygen requirement)
100        let has_description = ctx
101            .method
102            .doc_tags
103            .iter()
104            .any(|t| t.starts_with("@description ") || t.starts_with("@title "));
105        if !has_description {
106            lines.push(format!(
107                "    #' @description Create a new `{}`.",
108                class_name
109            ));
110        }
111        for tag in &ctx.method.doc_tags {
112            for line in tag.lines() {
113                let line = if line.starts_with("@title ") {
114                    line.replacen("@title ", "@description ", 1)
115                } else {
116                    line.to_string()
117                };
118                lines.push(format!("    #' {}", line));
119            }
120        }
121        // Document constructor params that aren't already documented
122        let ctor_mx_doc = ctx.match_arg_doc_placeholders();
123        for param in ctx.params.split(", ").filter(|p| !p.is_empty()) {
124            let param_name = param.split('=').next().unwrap_or(param).trim();
125            if param_name == ".ptr" {
126                continue;
127            }
128            let already_documented = ctx
129                .method
130                .doc_tags
131                .iter()
132                .any(|t| t.starts_with(&format!("@param {}", param_name)));
133            if !already_documented {
134                // match_arg'd constructor params get the write-time placeholder
135                // so the cdylib pass renders `One of "A", "B".` (#210).
136                let body = ctor_mx_doc
137                    .get(param_name)
138                    .map(String::as_str)
139                    .unwrap_or("(no documentation available)");
140                lines.push(format!("    #' @param {} {}", param_name, body));
141            }
142        }
143
144        // Only add trailing comma if there are public methods after initialize
145        let comma = if has_public_methods { "," } else { "" };
146
147        // Precondition checks for constructor parameters
148        let ctor_preconditions = ctx.precondition_checks();
149
150        // Missing param prelude for constructor
151        let ctor_missing = ctx.missing_prelude();
152
153        let ctor_match_arg = ctx.match_arg_prelude();
154
155        if has_self_returning_methods {
156            let full_params = if ctx.params.is_empty() {
157                ".ptr = NULL".to_string()
158            } else {
159                format!("{}, .ptr = NULL", ctx.params)
160            };
161            lines.push(format!("    initialize = function({}) {{", full_params));
162            // Missing defaults + preconditions + match.arg only when not using .ptr shortcut
163            if !ctor_missing.is_empty()
164                || !ctor_preconditions.is_empty()
165                || !ctor_match_arg.is_empty()
166            {
167                lines.push("      if (is.null(.ptr)) {".to_string());
168                for line in &ctor_missing {
169                    lines.push(format!("        {}", line));
170                }
171                for check in &ctor_preconditions {
172                    lines.push(format!("        {}", check));
173                }
174                for line in &ctor_match_arg {
175                    lines.push(format!("        {}", line));
176                }
177                lines.push("      }".to_string());
178            }
179            lines.push("      if (!is.null(.ptr)) {".to_string());
180            lines.push("        private$.ptr <- .ptr".to_string());
181            lines.push("      } else {".to_string());
182            lines.push(format!("        .val <- {}", ctx.static_call()));
183            // Use shared condition switch (supports error!/warning!/message!/condition!).
184            for check_line in crate::method_return_builder::condition_check_lines("        ") {
185                lines.push(check_line);
186            }
187            lines.push("        private$.ptr <- .val".to_string());
188            lines.push("      }".to_string());
189            lines.push(format!("    }}{}", comma));
190        } else {
191            lines.push(format!("    initialize = function({}) {{", ctx.params));
192            for line in &ctor_missing {
193                lines.push(format!("      {}", line));
194            }
195            for check in &ctor_preconditions {
196                lines.push(format!("      {}", check));
197            }
198            for line in &ctor_match_arg {
199                lines.push(format!("      {}", line));
200            }
201            lines.push(format!("      .val <- {}", ctx.static_call()));
202            lines.extend(crate::method_return_builder::condition_check_lines(
203                "      ",
204            ));
205            lines.push("      private$.ptr <- .val".to_string());
206            lines.push(format!("    }}{}", comma));
207        }
208    } else if has_self_returning_methods {
209        // No explicit new() constructor, but factory methods need $new(.ptr = val).
210        // Generate a minimal initialize that only accepts .ptr.
211        let comma = if has_public_methods { "," } else { "" };
212        lines.push(format!(
213            "    #' @description Create a new `{}`.",
214            class_name
215        ));
216        lines.push("    initialize = function(.ptr = NULL) {".to_string());
217        lines.push("      if (!is.null(.ptr)) {".to_string());
218        lines.push("        private$.ptr <- .ptr".to_string());
219        lines.push("      }".to_string());
220        lines.push(format!("    }}{}", comma));
221    }
222
223    // Public instance methods
224    for (i, ctx) in public_method_contexts.iter().enumerate() {
225        let comma = if i < public_method_contexts.len() - 1 {
226            ","
227        } else {
228            ""
229        };
230
231        lines.push(format!("    {}", ctx.source_comment(type_ident)));
232        // Add inline roxygen documentation for this method
233        // Note: @title is replaced with @description for R6 inline docs (roxygen requirement)
234        let r_name = ctx.method.r_method_name();
235        let has_description = ctx
236            .method
237            .doc_tags
238            .iter()
239            .any(|t| t.starts_with("@description ") || t.starts_with("@title "));
240        if !has_description {
241            lines.push(format!("    #' @description Method `{}`.", r_name));
242        }
243        for tag in &ctx.method.doc_tags {
244            for line in tag.lines() {
245                let line = if line.starts_with("@title ") {
246                    line.replacen("@title ", "@description ", 1)
247                } else {
248                    line.to_string()
249                };
250                lines.push(format!("    #' {}", line));
251            }
252        }
253        // Document method params that aren't already documented
254        let method_mx_doc = ctx.match_arg_doc_placeholders();
255        for param in ctx.params.split(", ").filter(|p| !p.is_empty()) {
256            let param_name = param.split('=').next().unwrap_or(param).trim();
257            let already_documented = ctx
258                .method
259                .doc_tags
260                .iter()
261                .any(|t| t.starts_with(&format!("@param {}", param_name)));
262            if !already_documented {
263                let body = method_mx_doc
264                    .get(param_name)
265                    .map(String::as_str)
266                    .unwrap_or("(no documentation available)");
267                lines.push(format!("    #' @param {} {}", param_name, body));
268            }
269        }
270        lines.push(format!("    {} = function({}) {{", r_name, ctx.params));
271
272        let what = format!("{}${}", class_name, r_name);
273        ctx.emit_method_prelude(&mut lines, "      ", &what);
274
275        let call = ctx.instance_call("private$.ptr");
276        let strategy = crate::ReturnStrategy::for_method(ctx.method);
277        let return_builder = crate::MethodReturnBuilder::new(call)
278            .with_strategy(strategy)
279            .with_class_name(class_name.clone())
280            .with_indent(6); // R6 methods have 6-space indent
281        lines.extend(return_builder.build_r6_body());
282
283        lines.push(format!("    }}{}", comma));
284    }
285
286    lines.push("  ),".to_string());
287
288    // Private list - includes .ptr and any private methods
289    lines.push("  private = list(".to_string());
290
291    // Private instance methods
292    for ctx in parsed_impl.private_instance_method_contexts() {
293        lines.push(format!("    {}", ctx.source_comment(type_ident)));
294        lines.push(format!(
295            "    {} = function({}) {{",
296            ctx.method.r_method_name(),
297            ctx.params
298        ));
299
300        // Inject r_entry
301        if let Some(ref entry) = ctx.method.method_attrs.r_entry {
302            for line in entry.lines() {
303                lines.push(format!("      {}", line));
304            }
305        }
306        // Inject on.exit cleanup
307        if let Some(ref on_exit) = ctx.method.method_attrs.r_on_exit {
308            lines.push(format!("      {}", on_exit.to_r_code()));
309        }
310        // Inject missing param defaults
311        for line in ctx.missing_prelude() {
312            lines.push(format!("      {}", line));
313        }
314        // Inject match.arg validation for match_arg/choices params
315        for line in ctx.match_arg_prelude() {
316            lines.push(format!("      {}", line));
317        }
318        // Inject r_post_checks
319        if let Some(ref post) = ctx.method.method_attrs.r_post_checks {
320            for line in post.lines() {
321                lines.push(format!("      {}", line));
322            }
323        }
324
325        let call = ctx.instance_call("private$.ptr");
326        let strategy = crate::ReturnStrategy::for_method(ctx.method);
327        let return_builder = crate::MethodReturnBuilder::new(call)
328            .with_strategy(strategy)
329            .with_class_name(class_name.clone())
330            .with_indent(6);
331        lines.extend(return_builder.build_r6_body());
332
333        lines.push("    },".to_string());
334    }
335
336    // Finalizer (if any)
337    if let Some(finalizer) = parsed_impl.finalizer() {
338        let c_ident = finalizer
339            .c_wrapper_ident(type_ident, parsed_impl.label())
340            .to_string();
341        let finalize_call = crate::r_wrapper_builder::DotCallBuilder::new(&c_ident)
342            .null_call_attribution()
343            .with_self("private$.ptr")
344            .build();
345        lines.push(format!("    finalize = function() {finalize_call},"));
346    }
347
348    // deep_clone (if any method marked with #[miniextendr(r6(deep_clone))])
349    if let Some(dc_method) = parsed_impl
350        .methods
351        .iter()
352        .find(|m| m.method_attrs.r6.deep_clone && m.should_include())
353    {
354        let c_ident = dc_method
355            .c_wrapper_ident(type_ident, parsed_impl.label())
356            .to_string();
357        let deep_clone_call = crate::r_wrapper_builder::DotCallBuilder::new(&c_ident)
358            .null_call_attribution()
359            .with_self("private$.ptr")
360            .with_args(&["name", "value"])
361            .build();
362        lines.push(format!(
363            "    deep_clone = function(name, value) {deep_clone_call},"
364        ));
365    }
366
367    // .ptr field (always last, no trailing comma)
368    lines.push("    .ptr = NULL".to_string());
369    lines.push("  ),".to_string());
370
371    // Active bindings list (for property-like access)
372    let active_method_contexts: Vec<_> = parsed_impl.active_instance_method_contexts().collect();
373    if !active_method_contexts.is_empty() {
374        lines.push("  active = list(".to_string());
375
376        for (i, ctx) in active_method_contexts.iter().enumerate() {
377            let comma = if i < active_method_contexts.len() - 1 {
378                ","
379            } else {
380                ""
381            };
382
383            // Add inline @field documentation for active bindings
384            // roxygen2 requires @field tags (not @description) for active bindings
385            let method_name = ctx.method.r_method_name();
386            let method_noexport =
387                ctx.method.method_attrs.noexport || ctx.method.method_attrs.internal;
388            if method_noexport {
389                // `@field name NULL` is documented as the roxygen2 8.0.0 opt-out, but
390                // `r6_resolve_fields` still emits "Undocumented R6 active binding"
391                // because `expected` is introspected from the class definition and
392                // is not pruned in sync with the NULL-description discard. Emit a
393                // minimal `(internal)` description: it satisfies the warning, keeps
394                // the binding clearly marked as internal in the rendered docs, and
395                // is short enough not to clutter the help page.
396                lines.push(format!("    #' @field {} (internal)", method_name));
397            } else if ctx.method.doc_tags.is_empty() {
398                lines.push(format!("    #' @field {} Active binding.", method_name));
399            } else {
400                for tag in &ctx.method.doc_tags {
401                    for (line_idx, line) in tag.lines().enumerate() {
402                        // Convert @description/@title to @field on first line only
403                        let line = if line_idx == 0 {
404                            if let Some(desc) = line.strip_prefix("@description ") {
405                                format!("@field {} {}", method_name, desc)
406                            } else if let Some(desc) = line.strip_prefix("@title ") {
407                                format!("@field {} {}", method_name, desc)
408                            } else if !line.starts_with('@') {
409                                // Plain doc comment - treat as field description
410                                format!("@field {} {}", method_name, line)
411                            } else {
412                                line.to_string()
413                            }
414                        } else {
415                            // Continuation lines stay as-is
416                            line.to_string()
417                        };
418                        lines.push(format!("    #' {}", line));
419                    }
420                }
421            }
422
423            // Determine the property name (from r6_prop or method name)
424            let prop_name = ctx
425                .method
426                .method_attrs
427                .r6
428                .prop
429                .clone()
430                .unwrap_or_else(|| ctx.method.r_method_name());
431
432            // Check if there's a matching setter for this property
433            let setter = parsed_impl.find_setter_for_prop(&prop_name);
434
435            if let Some(setter_method) = setter {
436                // Combined getter/setter active binding
437                // Format: name = function(value) { if (missing(value)) getter else setter }
438                lines.push(format!("    {} = function(value) {{", prop_name));
439                lines.push("      if (missing(value)) {".to_string());
440
441                // Getter call
442                let getter_call = ctx.instance_call("private$.ptr");
443                lines.push(format!("        {}", getter_call));
444
445                lines.push("      } else {".to_string());
446
447                // Setter call - construct directly
448                let setter_c_ident = setter_method
449                    .c_wrapper_ident(type_ident, parsed_impl.label.as_deref())
450                    .to_string();
451                let setter_call = crate::r_wrapper_builder::DotCallBuilder::new(&setter_c_ident)
452                    .with_self("private$.ptr")
453                    .with_args(&["value"])
454                    .build();
455                lines.push(format!("        {}", setter_call));
456                lines.push("        invisible(self)".to_string());
457
458                lines.push("      }".to_string());
459                lines.push(format!("    }}{}", comma));
460            } else {
461                // Getter-only active binding (no parameters besides self)
462                // Format: name = function() { ... }
463                lines.push(format!("    {} = function() {{", prop_name));
464
465                let call = ctx.instance_call("private$.ptr");
466                let strategy = crate::ReturnStrategy::for_method(ctx.method);
467                let return_builder = crate::MethodReturnBuilder::new(call)
468                    .with_strategy(strategy)
469                    .with_class_name(class_name.clone())
470                    .with_indent(6); // R6 active bindings have 6-space indent
471                lines.extend(return_builder.build_r6_body());
472
473                lines.push(format!("    }}{}", comma));
474            }
475        }
476
477        lines.push("  ),".to_string());
478    }
479
480    // Class options
481    let lock_objects = parsed_impl.r6_lock_objects.unwrap_or(true);
482    let lock_class = parsed_impl.r6_lock_class.unwrap_or(false);
483    let cloneable = parsed_impl.r6_cloneable.unwrap_or(false);
484    lines.push(format!(
485        "  lock_objects = {},",
486        if lock_objects { "TRUE" } else { "FALSE" }
487    ));
488    lines.push(format!(
489        "  lock_class = {},",
490        if lock_class { "TRUE" } else { "FALSE" }
491    ));
492    lines.push(format!(
493        "  cloneable = {}",
494        if cloneable { "TRUE" } else { "FALSE" }
495    ));
496    lines.push(")".to_string());
497
498    // If r_data_accessors is set, apply sidecar active bindings from #[derive(ExternalPtr)]
499    if parsed_impl.r_data_accessors {
500        let type_name = type_ident.to_string();
501        lines.push(format!(
502            ".rdata_active_bindings_{}({})",
503            type_name, class_name
504        ));
505    }
506
507    // Check if class has @noRd
508    let class_has_no_rd = crate::roxygen::has_roxygen_tag(class_doc_tags, "noRd");
509
510    // Static methods as separate functions on the class object
511    for ctx in parsed_impl.static_method_contexts() {
512        let method_name = ctx.method.r_method_name();
513        let static_method_name = format!("{}${}", class_name, method_name);
514        lines.push(String::new());
515
516        lines.push(ctx.source_comment(type_ident));
517        let method_doc =
518            MethodDocBuilder::new(&class_name, &method_name, type_ident, &ctx.method.doc_tags)
519                .with_name_prefix("$")
520                .with_class_no_rd(class_has_no_rd);
521        lines.extend(method_doc.build());
522
523        lines.push(format!(
524            "{} <- function({}) {{",
525            static_method_name, ctx.params
526        ));
527
528        let what = format!("{}${}", class_name, method_name);
529        ctx.emit_method_prelude(&mut lines, "  ", &what);
530
531        let strategy = crate::ReturnStrategy::for_method(ctx.method);
532        let return_builder = crate::MethodReturnBuilder::new(ctx.static_call())
533            .with_strategy(strategy)
534            .with_class_name(class_name.clone());
535        lines.extend(return_builder.build_r6_body());
536
537        lines.push("}".to_string());
538    }
539
540    lines.join("\n")
541}