Skip to main content

miniextendr_macros/miniextendr_impl/
r6_class.rs

1//! R6-class R wrapper generator.
2//!
3//! Generates an `R6::R6Class(...)` definition with **mutable, reference
4//! semantics**: `obj$method()` dispatches via R6, and `&mut self` methods
5//! modify state in place without re-binding `obj`. Supports private methods,
6//! active bindings (computed/settable properties), lifecycle hooks
7//! (`finalize`, `deep_clone`), and inheritance via `r6(inherit = ...)`.
8//! Slower than env (R6 dispatch chain) and requires the R6 package; no value
9//! semantics — for value-semantics formal OOP, use S7.
10
11use super::{ParsedImpl, ParsedMethod};
12use crate::r_class_formatter::class_ref_or_verbatim;
13
14/// Build the `stopifnot()` precondition lines for the setter branch of a
15/// combined getter/setter active binding.
16///
17/// This is the same precondition block the standalone `set_*` method gets via
18/// [`crate::r_class_formatter::MethodContext::precondition_checks`] — the
19/// active-binding branch used to skip it (audit 2026-07-06 finding 4), so
20/// `obj$prop <- "bad"` bypassed the R-level type check the standalone setter
21/// enforces.
22///
23/// R6 active bindings always receive the assigned value through a formal
24/// named `value`, while the Rust setter's parameter may have any name, so the
25/// first non-receiver parameter (the only one the binding forwards — see the
26/// `.with_args(&["value"])` call site) is renamed to `value` before the
27/// checks are built. Any additional parameters are ignored: the binding never
28/// passes them, and checks referencing their names would error at runtime.
29fn active_setter_precondition_checks(setter: &ParsedMethod) -> Vec<String> {
30    let Some(mut value_arg) = setter.sig.inputs.iter().find_map(|arg| match arg {
31        syn::FnArg::Typed(pat_type) => Some(pat_type.clone()),
32        syn::FnArg::Receiver(_) => None,
33    }) else {
34        return Vec::new();
35    };
36
37    let mut per_param = setter.method_attrs.per_param.clone();
38    if let syn::Pat::Ident(pat_ident) = value_arg.pat.as_mut() {
39        let rust_name = pat_ident.ident.to_string();
40        if rust_name != "value" {
41            if let Some(attrs) = per_param.remove(&rust_name) {
42                per_param.insert("value".to_string(), attrs);
43            }
44            pat_ident.ident = syn::Ident::new("value", pat_ident.ident.span());
45        }
46    }
47
48    let mut inputs: syn::punctuated::Punctuated<syn::FnArg, syn::Token![,]> =
49        syn::punctuated::Punctuated::new();
50    inputs.push(syn::FnArg::Typed(value_arg));
51
52    crate::r_class_formatter::build_method_precondition_checks(
53        &inputs,
54        &per_param,
55        setter.method_attrs.coerce,
56    )
57}
58
59/// Marker prefix emitted by the proc-macro when a subclass method param might be
60/// inherited from a parent class documented in-package. Resolved at write-time in
61/// `registry.rs` to either nothing (parent is documented → roxygen2 inherits) or
62/// `(no documentation available)` (parent not found or not in-package).
63pub(crate) const MX_INHERITED_PARAM_PREFIX: &str = ".__MX_INHERITED_PARAM__(";
64
65/// Generates the complete R wrapper string for an R6-style class.
66///
67/// Produces an `R6::R6Class(...)` definition that includes:
68/// - `initialize` method: calls the Rust `new` constructor, or accepts a pre-made `.ptr`
69///   when static methods return `Self` (factory pattern)
70/// - Public methods: one R function per `&self`/`&mut self` instance method
71/// - Private methods: methods marked with `#[miniextendr(private)]`
72/// - Active bindings: getter/setter properties via `#[miniextendr(r6(prop = "..."))]`
73/// - Private `.ptr` field: holds the `ExternalPtr` to the Rust struct
74/// - Finalizer: optional destructor called when the R6 object is garbage-collected
75/// - Deep clone: optional custom clone logic via `#[miniextendr(r6(deep_clone))]`
76/// - Static methods: emitted as `ClassName$method_name <- function(...)` outside the class
77/// - Class options: `lock_objects`, `lock_class`, `cloneable`, `portable`, `inherit`
78///
79/// Also generates roxygen2 documentation blocks for the class, its methods,
80/// and active bindings.
81pub fn generate_r6_r_wrapper(parsed_impl: &ParsedImpl) -> String {
82    use crate::r_class_formatter::{ClassDocBuilder, MethodDocBuilder, ParsedImplExt};
83
84    let class_name = parsed_impl.class_name();
85    let type_ident = &parsed_impl.type_ident;
86    let class_doc_tags = &parsed_impl.doc_tags;
87
88    // Check if .ptr parameter will be added to initialize (for static methods returning Self)
89    let has_self_returning_methods = parsed_impl
90        .methods
91        .iter()
92        .filter(|m| m.should_include())
93        .any(|m| m.returns_self());
94
95    let mut lines = Vec::new();
96
97    // Start R6Class definition with documentation
98    lines.extend(
99        ClassDocBuilder::new(&class_name, type_ident, class_doc_tags, "R6")
100            .with_imports("@importFrom R6 R6Class")
101            .with_export_control(parsed_impl.internal, parsed_impl.noexport)
102            .build(),
103    );
104    // Inject lifecycle imports from methods into class-level roxygen block
105    if let Some(lc_import) = crate::lifecycle::collect_lifecycle_imports(
106        parsed_impl
107            .methods
108            .iter()
109            .filter_map(|m| m.method_attrs.lifecycle.as_ref()),
110    ) {
111        // Insert before @export (which is last)
112        let insert_pos = lines.len().saturating_sub(1);
113        lines.insert(insert_pos, format!("#' {}", lc_import));
114    }
115
116    // Document .ptr param if initialize will have it (for static methods returning Self)
117    if has_self_returning_methods && !crate::roxygen::has_roxygen_tag(class_doc_tags, "param .ptr")
118    {
119        // Insert before @export (which is last)
120        let insert_pos = lines.len().saturating_sub(1);
121        lines.insert(
122            insert_pos,
123            "#' @param .ptr Internal pointer (used by static methods, not for direct use)."
124                .to_string(),
125        );
126    }
127    // R6Class definition — optionally include inherit.
128    // Use a placeholder so the resolver can look up the actual R class name
129    // at cdylib write time (handles `class = "Override"` on the parent).
130    if let Some(ref parent) = parsed_impl.r6_inherit {
131        let parent_ref = class_ref_or_verbatim(parent);
132        lines.push(format!(
133            "{} <- R6::R6Class(\"{}\", inherit = {},",
134            class_name, class_name, parent_ref
135        ));
136    } else {
137        lines.push(format!("{} <- R6::R6Class(\"{}\",", class_name, class_name));
138    }
139
140    // Portable flag (only emit if explicitly set to FALSE, since TRUE is default)
141    if parsed_impl.r6_portable == Some(false) {
142        lines.push("  portable = FALSE,".to_string());
143    }
144
145    // Public list
146    lines.push("  public = list(".to_string());
147
148    // Class-level @param names — params documented at class level (in class_doc_tags)
149    // that roxygen2 8.0.0 inherits into all method docs automatically. Constructor and
150    // method loops skip emitting `(no documentation available)` for covered names.
151    let class_param_names = &parsed_impl.class_param_names;
152
153    // Public instance methods (collect first to know if we need trailing comma on initialize)
154    let public_method_contexts: Vec<_> = parsed_impl.public_instance_method_contexts().collect();
155    let has_public_methods = !public_method_contexts.is_empty();
156
157    // Constructor (initialize) - accepts either normal params or a pre-made .ptr.
158    // If there's no explicit `new()` but there are factory methods returning Self,
159    // generate a minimal initialize(.ptr) so factories can call $new(.ptr = val).
160    if let Some(ctx) = parsed_impl.constructor_context() {
161        lines.push(format!("    {}", ctx.source_comment(type_ident)));
162        // Add inline roxygen documentation for initialize method
163        // Note: @title is replaced with @description for R6 inline docs (roxygen requirement)
164        let has_description = ctx
165            .method
166            .doc_tags
167            .iter()
168            .any(|t| t.starts_with("@description ") || t.starts_with("@title "));
169        if !has_description {
170            lines.push(format!(
171                "    #' @description Create a new `{}`.",
172                class_name
173            ));
174        }
175        for tag in &ctx.method.doc_tags {
176            for line in tag.lines() {
177                let line = if line.starts_with("@title ") {
178                    line.replacen("@title ", "@description ", 1)
179                } else {
180                    line.to_string()
181                };
182                lines.push(format!("    #' {}", line));
183            }
184        }
185        // Document constructor params that aren't already documented.
186        // Class-level @param tags (in class_doc_tags) are inherited by all methods
187        // via roxygen2 8.0.0 — skip emitting a placeholder for params covered there.
188        let ctor_mx_doc = ctx.match_arg_doc_placeholders();
189        for param in ctx.params.split(", ").filter(|p| !p.is_empty()) {
190            let param_name = param.split('=').next().unwrap_or(param).trim();
191            if param_name == ".ptr" {
192                continue;
193            }
194            let already_documented = ctx
195                .method
196                .doc_tags
197                .iter()
198                .any(|t| t.starts_with(&format!("@param {}", param_name)));
199            if !already_documented {
200                // Param covered by class-level @param → roxygen2 inherits; emit nothing.
201                if class_param_names.contains(param_name) {
202                    continue;
203                }
204                // match_arg'd constructor params get the write-time placeholder
205                // so the cdylib pass renders `One of "A", "B".` (#210).
206                let body = ctor_mx_doc
207                    .get(param_name)
208                    .map(String::as_str)
209                    .unwrap_or("(no documentation available)");
210                lines.push(format!("    #' @param {} {}", param_name, body));
211            }
212        }
213
214        // Only add trailing comma if there are public methods after initialize
215        let comma = if has_public_methods { "," } else { "" };
216
217        // Precondition checks for constructor parameters
218        let ctor_preconditions = ctx.precondition_checks();
219
220        // Missing param prelude for constructor
221
222        let ctor_match_arg = ctx.match_arg_prelude();
223
224        if has_self_returning_methods {
225            let full_params = if ctx.params.is_empty() {
226                ".ptr = NULL".to_string()
227            } else {
228                format!("{}, .ptr = NULL", ctx.params)
229            };
230            lines.push(format!("    initialize = function({}) {{", full_params));
231            // Preconditions + match.arg only when not using .ptr shortcut
232            if !ctor_preconditions.is_empty() || !ctor_match_arg.is_empty() {
233                lines.push("      if (is.null(.ptr)) {".to_string());
234                for check in &ctor_preconditions {
235                    lines.push(format!("        {}", check));
236                }
237                for line in &ctor_match_arg {
238                    lines.push(format!("        {}", line));
239                }
240                lines.push("      }".to_string());
241            }
242            lines.push("      if (!is.null(.ptr)) {".to_string());
243            lines.push("        private$.ptr <- .ptr".to_string());
244            lines.push("      } else {".to_string());
245            lines.push(format!("        .val <- {}", ctx.static_call()));
246            // Use shared condition switch (supports error!/warning!/message!/condition!).
247            for check_line in crate::method_return_builder::condition_check_lines("        ") {
248                lines.push(check_line);
249            }
250            lines.push("        private$.ptr <- .val".to_string());
251            lines.push("      }".to_string());
252            lines.push(format!("    }}{}", comma));
253        } else {
254            lines.push(format!("    initialize = function({}) {{", ctx.params));
255            for check in &ctor_preconditions {
256                lines.push(format!("      {}", check));
257            }
258            for line in &ctor_match_arg {
259                lines.push(format!("      {}", line));
260            }
261            lines.push(format!("      .val <- {}", ctx.static_call()));
262            lines.extend(crate::method_return_builder::condition_check_lines(
263                "      ",
264            ));
265            lines.push("      private$.ptr <- .val".to_string());
266            lines.push(format!("    }}{}", comma));
267        }
268    } else if has_self_returning_methods {
269        // No explicit new() constructor, but factory methods need $new(.ptr = val).
270        // Generate a minimal initialize that only accepts .ptr.
271        let comma = if has_public_methods { "," } else { "" };
272        lines.push(format!(
273            "    #' @description Create a new `{}`.",
274            class_name
275        ));
276        lines.push("    initialize = function(.ptr = NULL) {".to_string());
277        lines.push("      if (!is.null(.ptr)) {".to_string());
278        lines.push("        private$.ptr <- .ptr".to_string());
279        lines.push("      }".to_string());
280        lines.push(format!("    }}{}", comma));
281    }
282
283    // Public instance methods
284    for (i, ctx) in public_method_contexts.iter().enumerate() {
285        let comma = if i < public_method_contexts.len() - 1 {
286            ","
287        } else {
288            ""
289        };
290
291        lines.push(format!("    {}", ctx.source_comment(type_ident)));
292        // Add inline roxygen documentation for this method
293        // Note: @title is replaced with @description for R6 inline docs (roxygen requirement)
294        let r_name = ctx.method.r_method_name();
295        let has_description = ctx
296            .method
297            .doc_tags
298            .iter()
299            .any(|t| t.starts_with("@description ") || t.starts_with("@title "));
300        if !has_description {
301            lines.push(format!("    #' @description Method `{}`.", r_name));
302        }
303        for tag in &ctx.method.doc_tags {
304            for line in tag.lines() {
305                let line = if line.starts_with("@title ") {
306                    line.replacen("@title ", "@description ", 1)
307                } else {
308                    line.to_string()
309                };
310                lines.push(format!("    #' {}", line));
311            }
312        }
313        // Document method params that aren't already documented.
314        // Class-level @param tags are inherited by roxygen2 8.0.0 — skip covered params.
315        // For subclass methods (r6_inherit set) and params not covered at class level,
316        // emit a write-time marker so the registry can detect in-package parent docs
317        // and suppress the placeholder (letting roxygen2 pull from the parent method).
318        let method_mx_doc = ctx.match_arg_doc_placeholders();
319        let r_method_name = ctx.method.r_method_name();
320        for param in ctx.params.split(", ").filter(|p| !p.is_empty()) {
321            let param_name = param.split('=').next().unwrap_or(param).trim();
322            let already_documented = ctx
323                .method
324                .doc_tags
325                .iter()
326                .any(|t| t.starts_with(&format!("@param {}", param_name)));
327            if !already_documented {
328                // Param covered by class-level @param → roxygen2 inherits; emit nothing.
329                if class_param_names.contains(param_name) {
330                    continue;
331                }
332                let body = method_mx_doc
333                    .get(param_name)
334                    .map(String::as_str)
335                    .unwrap_or("(no documentation available)");
336                // For subclass methods: if there is an in-package parent, emit a
337                // write-time marker. The registry pass will resolve it to nothing
338                // (parent documented → roxygen2 inherits) or to the fallback text.
339                if let Some(ref parent) = parsed_impl.r6_inherit
340                    && body == "(no documentation available)"
341                {
342                    lines.push(format!(
343                        "    #' {}class=\"{}\", parent=\"{}\", method=\"{}\", param=\"{}\")",
344                        MX_INHERITED_PARAM_PREFIX, class_name, parent, r_method_name, param_name,
345                    ));
346                } else {
347                    lines.push(format!("    #' @param {} {}", param_name, body));
348                }
349            }
350        }
351        lines.push(format!("    {} = function({}) {{", r_name, ctx.params));
352
353        let what = format!("{}${}", class_name, r_name);
354        ctx.emit_method_prelude(&mut lines, "      ", &what);
355
356        let call = ctx.instance_call("private$.ptr");
357        let strategy = crate::ReturnStrategy::for_method(ctx.method);
358        let return_builder = crate::MethodReturnBuilder::new(call)
359            .with_strategy(strategy)
360            .with_class_name(class_name.clone())
361            .with_indent(6); // R6 methods have 6-space indent
362        lines.extend(return_builder.build_r6_body());
363
364        lines.push(format!("    }}{}", comma));
365    }
366
367    lines.push("  ),".to_string());
368
369    // Private list - includes .ptr and any private methods
370    lines.push("  private = list(".to_string());
371
372    // Private instance methods
373    for ctx in parsed_impl.private_instance_method_contexts() {
374        lines.push(format!("    {}", ctx.source_comment(type_ident)));
375        lines.push(format!(
376            "    {} = function({}) {{",
377            ctx.method.r_method_name(),
378            ctx.params
379        ));
380
381        // Inject r_entry
382        if let Some(ref entry) = ctx.method.method_attrs.r_entry {
383            for line in entry.lines() {
384                lines.push(format!("      {}", line));
385            }
386        }
387        // Inject on.exit cleanup
388        if let Some(ref on_exit) = ctx.method.method_attrs.r_on_exit {
389            lines.push(format!("      {}", on_exit.to_r_code()));
390        }
391        // Inject missing param defaults
392        // Inject match.arg validation for match_arg/choices params
393        for line in ctx.match_arg_prelude() {
394            lines.push(format!("      {}", line));
395        }
396        // Inject r_post_checks
397        if let Some(ref post) = ctx.method.method_attrs.r_post_checks {
398            for line in post.lines() {
399                lines.push(format!("      {}", line));
400            }
401        }
402
403        let call = ctx.instance_call("private$.ptr");
404        let strategy = crate::ReturnStrategy::for_method(ctx.method);
405        let return_builder = crate::MethodReturnBuilder::new(call)
406            .with_strategy(strategy)
407            .with_class_name(class_name.clone())
408            .with_indent(6);
409        lines.extend(return_builder.build_r6_body());
410
411        lines.push("    },".to_string());
412    }
413
414    // Finalizer (if any)
415    if let Some(finalizer) = parsed_impl.finalizer() {
416        let c_ident = finalizer
417            .c_wrapper_ident(type_ident, parsed_impl.label())
418            .to_string();
419        let finalize_call = crate::r_wrapper_builder::DotCallBuilder::new(&c_ident)
420            .null_call_attribution()
421            .with_self("private$.ptr")
422            .build();
423        lines.push(format!("    finalize = function() {finalize_call},"));
424    }
425
426    // deep_clone (if any method marked with #[miniextendr(r6(deep_clone))])
427    if let Some(dc_method) = parsed_impl
428        .methods
429        .iter()
430        .find(|m| m.method_attrs.r6.deep_clone && m.should_include())
431    {
432        let c_ident = dc_method
433            .c_wrapper_ident(type_ident, parsed_impl.label())
434            .to_string();
435        let deep_clone_call = crate::r_wrapper_builder::DotCallBuilder::new(&c_ident)
436            .null_call_attribution()
437            .with_self("private$.ptr")
438            .with_args(&["name", "value"])
439            .build();
440        lines.push(format!(
441            "    deep_clone = function(name, value) {deep_clone_call},"
442        ));
443    }
444
445    // .ptr field (always last, no trailing comma)
446    lines.push("    .ptr = NULL".to_string());
447    lines.push("  ),".to_string());
448
449    // Active bindings list (for property-like access)
450    let active_method_contexts: Vec<_> = parsed_impl.active_instance_method_contexts().collect();
451    if !active_method_contexts.is_empty() {
452        lines.push("  active = list(".to_string());
453
454        for (i, ctx) in active_method_contexts.iter().enumerate() {
455            let comma = if i < active_method_contexts.len() - 1 {
456                ","
457            } else {
458                ""
459            };
460
461            // Add inline @field documentation for active bindings
462            // roxygen2 requires @field tags (not @description) for active bindings
463            let method_name = ctx.method.r_method_name();
464            let method_noexport =
465                ctx.method.method_attrs.noexport || ctx.method.method_attrs.internal;
466            if method_noexport {
467                // `@field name NULL` is documented as the roxygen2 8.0.0 opt-out, but
468                // `r6_resolve_fields` still emits "Undocumented R6 active binding"
469                // because `expected` is introspected from the class definition and
470                // is not pruned in sync with the NULL-description discard. Emit a
471                // minimal `(internal)` description: it satisfies the warning, keeps
472                // the binding clearly marked as internal in the rendered docs, and
473                // is short enough not to clutter the help page.
474                lines.push(format!("    #' @field {} (internal)", method_name));
475            } else if ctx.method.doc_tags.is_empty() {
476                lines.push(format!("    #' @field {} Active binding.", method_name));
477            } else {
478                for tag in &ctx.method.doc_tags {
479                    for (line_idx, line) in tag.lines().enumerate() {
480                        // Convert @description/@title to @field on first line only
481                        let line = if line_idx == 0 {
482                            if let Some(desc) = line.strip_prefix("@description ") {
483                                format!("@field {} {}", method_name, desc)
484                            } else if let Some(desc) = line.strip_prefix("@title ") {
485                                format!("@field {} {}", method_name, desc)
486                            } else if !line.starts_with('@') {
487                                // Plain doc comment - treat as field description
488                                format!("@field {} {}", method_name, line)
489                            } else {
490                                line.to_string()
491                            }
492                        } else {
493                            // Continuation lines stay as-is
494                            line.to_string()
495                        };
496                        lines.push(format!("    #' {}", line));
497                    }
498                }
499            }
500
501            // Determine the property name (from r6_prop or method name)
502            let prop_name = ctx
503                .method
504                .method_attrs
505                .r6
506                .prop
507                .clone()
508                .unwrap_or_else(|| ctx.method.r_method_name());
509
510            // Check if there's a matching setter for this property
511            let setter = parsed_impl.find_setter_for_prop(&prop_name);
512
513            if let Some(setter_method) = setter {
514                // Combined getter/setter active binding
515                // Format: name = function(value) { if (missing(value)) getter else setter }
516                lines.push(format!("    {} = function(value) {{", prop_name));
517                lines.push("      if (missing(value)) {".to_string());
518
519                // Getter call — same condition re-raise guard as the
520                // getter-only active binding path below.
521                let getter_call = ctx.instance_call("private$.ptr");
522                let getter_strategy = crate::ReturnStrategy::for_method(ctx.method);
523                let getter_builder = crate::MethodReturnBuilder::new(getter_call)
524                    .with_strategy(getter_strategy)
525                    .with_class_name(class_name.clone())
526                    .with_indent(8);
527                lines.extend(getter_builder.build_r6_body());
528
529                lines.push("      } else {".to_string());
530
531                // Same `stopifnot` precondition block the standalone `set_*`
532                // method gets (audit 2026-07-06 finding 4): without it,
533                // `obj$prop <- <bad value>` skipped the R-level type check.
534                for check in active_setter_precondition_checks(setter_method) {
535                    lines.push(format!("        {}", check));
536                }
537
538                // Setter call — construct directly, then re-raise any
539                // transported Rust condition (the bare `.Call()` used to
540                // discard the tagged condition value, silently dropping the
541                // assignment error).
542                let setter_c_ident = setter_method
543                    .c_wrapper_ident(type_ident, parsed_impl.label.as_deref())
544                    .to_string();
545                let setter_call = crate::r_wrapper_builder::DotCallBuilder::new(&setter_c_ident)
546                    .with_self("private$.ptr")
547                    .with_args(&["value"])
548                    .build();
549                lines.push(format!("        .val <- {}", setter_call));
550                lines.extend(crate::method_return_builder::condition_check_lines(
551                    "        ",
552                ));
553                lines.push("        invisible(self)".to_string());
554
555                lines.push("      }".to_string());
556                lines.push(format!("    }}{}", comma));
557            } else {
558                // Getter-only active binding (no parameters besides self)
559                // Format: name = function() { ... }
560                lines.push(format!("    {} = function() {{", prop_name));
561
562                let call = ctx.instance_call("private$.ptr");
563                let strategy = crate::ReturnStrategy::for_method(ctx.method);
564                let return_builder = crate::MethodReturnBuilder::new(call)
565                    .with_strategy(strategy)
566                    .with_class_name(class_name.clone())
567                    .with_indent(6); // R6 active bindings have 6-space indent
568                lines.extend(return_builder.build_r6_body());
569
570                lines.push(format!("    }}{}", comma));
571            }
572        }
573
574        lines.push("  ),".to_string());
575    }
576
577    // Class options
578    let lock_objects = parsed_impl.r6_lock_objects.unwrap_or(true);
579    let lock_class = parsed_impl.r6_lock_class.unwrap_or(false);
580    let cloneable = parsed_impl.r6_cloneable.unwrap_or(false);
581    lines.push(format!(
582        "  lock_objects = {},",
583        if lock_objects { "TRUE" } else { "FALSE" }
584    ));
585    lines.push(format!(
586        "  lock_class = {},",
587        if lock_class { "TRUE" } else { "FALSE" }
588    ));
589    lines.push(format!(
590        "  cloneable = {}",
591        if cloneable { "TRUE" } else { "FALSE" }
592    ));
593    lines.push(")".to_string());
594
595    // If r_data_accessors is set, apply sidecar active bindings from #[derive(ExternalPtr)]
596    if parsed_impl.r_data_accessors {
597        let type_name = type_ident.to_string();
598        lines.push(format!(
599            ".rdata_active_bindings_{}({})",
600            type_name, class_name
601        ));
602    }
603
604    // Check if class has @noRd. A plain `noexport` (without `internal`) is folded
605    // in here too — it must suppress Rd contribution entirely, matching
606    // `ClassDocBuilder::build`'s `suppress_rd` gate (see r_class_formatter.rs).
607    let class_has_no_rd = crate::roxygen::has_roxygen_tag(class_doc_tags, "noRd")
608        || (parsed_impl.noexport && !parsed_impl.internal);
609
610    // Static methods as separate functions on the class object
611    for ctx in parsed_impl.static_method_contexts() {
612        let method_name = ctx.method.r_method_name();
613        let static_method_name = format!("{}${}", class_name, method_name);
614        lines.push(String::new());
615
616        lines.push(ctx.source_comment(type_ident));
617        let method_doc =
618            MethodDocBuilder::new(&class_name, &method_name, type_ident, &ctx.method.doc_tags)
619                .with_name_prefix("$")
620                .with_class_no_rd(class_has_no_rd);
621        lines.extend(method_doc.build());
622
623        lines.push(format!(
624            "{} <- function({}) {{",
625            static_method_name, ctx.params
626        ));
627
628        let what = format!("{}${}", class_name, method_name);
629        ctx.emit_method_prelude(&mut lines, "  ", &what);
630
631        let strategy = crate::ReturnStrategy::for_method(ctx.method);
632        let return_builder = crate::MethodReturnBuilder::new(ctx.static_call())
633            .with_strategy(strategy)
634            .with_class_name(class_name.clone());
635        lines.extend(return_builder.build_r6_body());
636
637        lines.push("}".to_string());
638    }
639
640    lines.join("\n")
641}