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//!
11//! ## Emission shape (`$set()` form, #369)
12//!
13//! roxygen2 8.0.0 documents R6 methods added *after* class creation via
14//! `Class$set("public"/"active", "name", function(...) {...})` from the roxygen
15//! block directly preceding the `$set` call. We emit a minimal `R6Class` core
16//! (only `initialize` inline in `public`, plus the private list) followed by one
17//! `$set` block per public method. Active-binding `@field` tags stay on the
18//! class block: roxygen2's dynamic `$set()` parser treats every target as a
19//! method, including `$set("active", ...)`, so putting field tags immediately
20//! above an active-binding call produces spurious "can't find matching R6
21//! method" warnings.
22//!
23//! ```r
24//! #' @field binding_name <binding doc>
25//! ClassName <- R6::R6Class("ClassName",
26//!   public = list(initialize = function(...) { ... }),
27//!   private = list(.ptr = NULL),
28//!   lock_objects = TRUE, lock_class = FALSE, cloneable = FALSE
29//! )
30//!
31//! #' @description <method doc>
32//! #' @param x <param doc>
33//! ClassName$set("public", "method_name", function(x) { ... })
34//!
35//! # No adjacent roxygen block: the @field tag is on the class documentation.
36//! ClassName$set("active", "binding_name", function(value) { ... })
37//! ```
38//!
39//! `initialize` stays inline (R6Class needs a well-formed core; its `$new` doc
40//! stays on the class page), and so does the `.ptr` field in the `private`
41//! list. Private methods, `finalize` and `deep_clone` are
42//! `$set("private", ...)` blocks without roxygen, so that every impl block of a
43//! type adds its own (#1776). The wrapper writer emits every block's `$set`
44//! calls after the class definition.
45//!
46//! Because R6's `$set()` refuses to modify a locked class, the generator always
47//! creates the class with `lock_class = FALSE` and, when the user asked for
48//! `lock_class = TRUE`, re-locks it with `ClassName$lock()` after every `$set`
49//! (including sidecar accessors) has run. The observable semantics are identical
50//! to a class born locked — the unlocked window is confined to package load.
51
52use super::{ParsedImpl, ParsedMethod};
53use crate::r_class_formatter::class_ref_or_verbatim;
54
55/// Build the precondition guard lines for the setter branch of a
56/// combined getter/setter active binding.
57///
58/// This is the same precondition block the standalone `set_*` method gets via
59/// [`crate::r_class_formatter::MethodContext::precondition_checks`] — the
60/// active-binding branch used to skip it (audit 2026-07-06 finding 4), so
61/// `obj$prop <- "bad"` bypassed the R-level type check the standalone setter
62/// enforces.
63///
64/// R6 active bindings always receive the assigned value through a formal
65/// named `value`, while the Rust setter's parameter may have any name, so the
66/// first non-receiver parameter (the only one the binding forwards — see the
67/// `.with_args(&["value"])` call site) is renamed to `value` before the
68/// checks are built, and a `formal` it declares (#1842) is dropped, since it
69/// names the `set_*` method's argument. Any additional parameters are
70/// ignored: the binding never passes them, and checks referencing their names
71/// would error at runtime.
72///
73/// `impl_preconditions` is the impl block's `preconditions` /
74/// `no_preconditions`; with the setter's own spellings (the method's, the
75/// parameter's) the binding keeps or drops the type checks exactly when the
76/// `set_*` method does. The per-parameter decision moves to `value` with the
77/// rest of the parameter's attributes, before the checks are resolved.
78fn active_setter_precondition_checks(
79    setter: &ParsedMethod,
80    impl_preconditions: Option<bool>,
81) -> Vec<String> {
82    let Some(mut value_arg) = setter.sig.inputs.iter().find_map(|arg| match arg {
83        syn::FnArg::Typed(pat_type) => Some(pat_type.clone()),
84        syn::FnArg::Receiver(_) => None,
85    }) else {
86        return Vec::new();
87    };
88
89    let mut per_param = setter.method_attrs.per_param.clone();
90    if let syn::Pat::Ident(pat_ident) = value_arg.pat.as_mut() {
91        let rust_name = crate::naming::ident_name(&pat_ident.ident);
92        // A declared `formal` names the `set_*` method's argument, not the
93        // binding's (#1842): the binding's formal is `value` whatever it is.
94        if let Some(mut attrs) = per_param.remove(&rust_name) {
95            attrs.formal = None;
96            per_param.insert("value".to_string(), attrs);
97        }
98        pat_ident.ident = syn::Ident::new("value", pat_ident.ident.span());
99    }
100
101    let mut inputs: syn::punctuated::Punctuated<syn::FnArg, ::syn::token::Commasyn::Token![,]> =
102        syn::punctuated::Punctuated::new();
103    inputs.push(syn::FnArg::Typed(value_arg));
104
105    crate::r_class_formatter::build_method_precondition_checks(
106        &inputs,
107        &per_param,
108        setter.method_attrs.coerce,
109        setter.method_attrs.preconditions,
110        impl_preconditions,
111        None,
112    )
113}
114
115/// Build class-level `@field` documentation for an R6 active binding.
116///
117/// roxygen2 8.0's dynamic `$set()` parser does not distinguish `public` from
118/// `active`: a roxygen block immediately before either call is classified as
119/// method documentation. Active-binding docs therefore belong on the class
120/// block, where roxygen2 resolves them against the runtime class's active
121/// bindings without inventing a method owner.
122fn field_tag_documents(tag: &str, field_name: &str) -> bool {
123    let Some(rest) = tag.trim_start().strip_prefix("@field ") else {
124        return false;
125    };
126    let Some(names) = rest.split_whitespace().next() else {
127        return false;
128    };
129    names.split(',').any(|name| name.trim() == field_name)
130}
131
132fn active_binding_field_lines(method: &ParsedMethod, class_doc_tags: &[String]) -> Vec<String> {
133    let prop_name = method
134        .method_attrs
135        .r6
136        .prop
137        .clone()
138        .unwrap_or_else(|| method.r_method_name());
139
140    if class_doc_tags
141        .iter()
142        .any(|tag| field_tag_documents(tag, &prop_name))
143    {
144        return Vec::new();
145    }
146
147    if method.method_attrs.noexport || method.method_attrs.internal {
148        return ::alloc::boxed::box_assume_init_into_vec_unsafe(::alloc::intrinsics::write_box_via_move(::alloc::boxed::Box::new_uninit(),
        [super::class_default(::alloc::__export::must_use({
                            ::alloc::fmt::format(format_args!("#\' @field {0} (internal)",
                                    prop_name))
                        }))]))vec![super::class_default(format!(
149            "#' @field {prop_name} (internal)"
150        ))];
151    }
152
153    let description = method.doc_tags.iter().find_map(|tag| {
154        tag.strip_prefix("@field ")
155            .and_then(|rest| rest.split_once(char::is_whitespace).map(|(_, desc)| desc))
156            .or_else(|| tag.strip_prefix("@description "))
157            .or_else(|| tag.strip_prefix("@title "))
158    });
159    let Some(description) = description else {
160        return ::alloc::boxed::box_assume_init_into_vec_unsafe(::alloc::intrinsics::write_box_via_move(::alloc::boxed::Box::new_uninit(),
        [super::class_default(::alloc::__export::must_use({
                            ::alloc::fmt::format(format_args!("#\' @field {0} Active binding.",
                                    prop_name))
                        }))]))vec![super::class_default(format!(
161            "#' @field {prop_name} Active binding."
162        ))];
163    };
164
165    let mut description_lines = description.lines();
166    let first = description_lines.next().unwrap_or("Active binding.");
167    let mut lines = ::alloc::boxed::box_assume_init_into_vec_unsafe(::alloc::intrinsics::write_box_via_move(::alloc::boxed::Box::new_uninit(),
        [super::class_default(::alloc::__export::must_use({
                            ::alloc::fmt::format(format_args!("#\' @field {0} {1}",
                                    prop_name, first))
                        }))]))vec![super::class_default(format!(
168        "#' @field {prop_name} {first}"
169    ))];
170    lines.extend(description_lines.map(crate::roxygen::roxygen_line));
171    lines
172}
173
174/// Marker prefix emitted by the proc-macro when a subclass method param might be
175/// inherited from a parent class documented in-package. Resolved at write-time in
176/// `registry.rs` to either nothing (parent is documented → roxygen2 inherits) or
177/// `(no documentation available)` (parent not found or not in-package).
178pub(crate) const MX_INHERITED_PARAM_PREFIX: &str = ".__MX_INHERITED_PARAM__(";
179
180/// Generates the R wrapper of an R6-style class, in the parts the wrapper
181/// writer merges per type ([`super::ClassWrapper`], #1776).
182///
183/// Produces an `R6::R6Class(...)` core definition followed by per-method
184/// `$set()` blocks (see the module docs for the full `$set`-form shape, #369):
185/// - `initialize` method (inline in `public`): calls the Rust constructor, or
186///   accepts a pre-made `.ptr` when a factory returns `Self`; a class without
187///   a constructor accepts only the `.ptr` and raises the no-constructor error
188///   without one
189/// - Public methods: one `ClassName$set("public", "name", function(...) {...})`
190///   block per `&self`/`&mut self` instance method, each with its own roxygen
191/// - Private methods: one `ClassName$set("private", "name", function(...) {...})`
192///   block per method marked with `#[miniextendr(private)]`
193/// - Active bindings: one `ClassName$set("active", "name", function(...) {...})`
194///   block per getter/setter property (`#[miniextendr(r6(prop = "..."))]`)
195/// - Private `.ptr` field (inline in `private`): holds the `ExternalPtr` to the Rust struct
196/// - Finalizer (`$set("private", "finalize", ...)`): optional destructor called when the
197///   R6 object is garbage-collected
198/// - Deep clone (`$set("private", "deep_clone", ...)`): optional custom clone logic via
199///   `#[miniextendr(r6(deep_clone))]`
200/// - Static methods: emitted as `ClassName$method_name <- function(...)` outside the class
201/// - Class options: `lock_objects`, `lock_class`, `cloneable`, `portable`, `inherit`
202///
203/// Also generates roxygen2 documentation blocks for the class, its methods,
204/// and active bindings.
205///
206/// The `R6Class()` core is the head; the `$set()` blocks and the static
207/// methods are the members; the sidecar active bindings and `$lock()` are the
208/// tail, after every block's `$set()`.
209pub fn generate_r6_class(parsed_impl: &ParsedImpl) -> super::ClassWrapper {
210    use crate::r_class_formatter::{ClassDocBuilder, MethodDocBuilder, ParsedImplExt};
211
212    let class_name = parsed_impl.class_name();
213    let type_ident = &parsed_impl.type_ident;
214    let class_doc_tags = &parsed_impl.doc_tags;
215
216    let mut w = super::ClassWrapper::default();
217    let mut lines = Vec::new();
218
219    // Start R6Class definition with documentation
220    let class_docs = ClassDocBuilder::new(&class_name, type_ident, class_doc_tags, "R6")
221        .with_imports("@importFrom R6 R6Class")
222        .with_export_control(parsed_impl.internal, parsed_impl.noexport);
223    lines.extend(class_docs.build());
224    w.class_docs = class_docs.docs();
225    // Every R6 ExternalPtr class accepts .ptr so write-time cross-class return
226    // wrapping can land in the target constructor.
227    if !crate::roxygen::has_roxygen_tag(class_doc_tags, "param .ptr") {
228        // Insert before @export (which is last)
229        let insert_pos = lines.len().saturating_sub(1);
230        lines.insert(
231            insert_pos,
232            super::class_default(
233                "#' @param .ptr Internal pointer (used by static methods, not for direct use).",
234            ),
235        );
236    }
237    w.mark_class_doc_lines(&mut lines, parsed_impl);
238
239    // Active bindings are documented on the class block. roxygen2 8.0 parses
240    // every dynamic `$set()` target as a method, even for `$set("active", ...)`;
241    // an adjacent `@field` block consequently warns that it cannot find a
242    // matching R6 method. Class-level fields are resolved against the runtime
243    // R6 class and retain the same rendered Active bindings section.
244    let active_method_contexts: Vec<_> = parsed_impl.active_instance_method_contexts().collect();
245    w.class_doc_lines.extend(
246        active_method_contexts
247            .iter()
248            .flat_map(|ctx| active_binding_field_lines(ctx.method, class_doc_tags)),
249    );
250
251    // R6Class definition — optionally include inherit.
252    // Use a placeholder so the resolver can look up the actual R class name
253    // at wrapper write time (handles `class = "Override"` on the parent).
254    if let Some(ref parent) = parsed_impl.r6_inherit {
255        let parent_ref = class_ref_or_verbatim(parent);
256        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} <- R6::R6Class(\"{1}\", inherit = {2},",
                class_name, class_name, parent_ref))
    })format!(
257            "{} <- R6::R6Class(\"{}\", inherit = {},",
258            class_name, class_name, parent_ref
259        ));
260    } else {
261        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} <- R6::R6Class(\"{1}\",",
                class_name, class_name))
    })format!("{} <- R6::R6Class(\"{}\",", class_name, class_name));
262    }
263
264    // Portable flag (only emit if explicitly set to FALSE, since TRUE is default)
265    if parsed_impl.r6_portable == Some(false) {
266        lines.push("  portable = FALSE,".to_string());
267    }
268
269    // Public list
270    lines.push("  public = list(".to_string());
271
272    // Class-level @param names — params documented at class level (in class_doc_tags)
273    // that roxygen2 8.0.0 inherits into all method docs automatically. Constructor and
274    // method loops skip emitting `(no documentation available)` for covered names.
275    let class_param_names = &parsed_impl.class_param_names;
276
277    // Constructor (initialize) - accepts either normal params or a pre-made .ptr.
278    // Without a constructor, `initialize` takes only the `.ptr` the factories
279    // and other classes' methods wrap with `$new(.ptr = val)`.
280    if let Some(ctx) = parsed_impl.constructor_context() {
281        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    {0}",
                ctx.source_comment(type_ident)))
    })format!("    {}", ctx.source_comment(type_ident)));
282        // Add inline roxygen documentation for initialize method
283        // Note: @title is replaced with @description for R6 inline docs (roxygen requirement)
284        let has_description = ctx
285            .method
286            .doc_tags
287            .iter()
288            .any(|t| t.starts_with("@description ") || t.starts_with("@title "));
289        if !has_description {
290            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    #\' @description Create a new `{0}`.",
                class_name))
    })format!(
291                "    #' @description Create a new `{}`.",
292                class_name
293            ));
294        }
295        for tag in &ctx.method.doc_tags {
296            for line in tag.lines() {
297                let line = if line.starts_with("@title ") {
298                    line.replacen("@title ", "@description ", 1)
299                } else {
300                    line.to_string()
301                };
302                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    {0}",
                crate::roxygen::roxygen_line(&line)))
    })format!("    {}", crate::roxygen::roxygen_line(&line)));
303            }
304        }
305        // Document constructor params that aren't already documented.
306        // Class-level @param tags (in class_doc_tags) are inherited by all methods
307        // via roxygen2 8.0.0 — skip emitting a placeholder for params covered there.
308        let ctor_mx_doc = ctx.choice_param_docs();
309        for param in ctx.params.split(", ").filter(|p| !p.is_empty()) {
310            let param_name = param.split('=').next().unwrap_or(param).trim();
311            if param_name == ".ptr" {
312                continue;
313            }
314            let already_documented =
315                crate::roxygen::param_documented(&ctx.method.doc_tags, param_name);
316            if !already_documented {
317                // Param covered by class-level @param → roxygen2 inherits; emit nothing.
318                if class_param_names.contains(param_name) {
319                    continue;
320                }
321                // Choice params get their choice text: the literal
322                // `choices(...)` line, or the write-time placeholder the
323                // wrapper writer renders to `One of "A", "B".` (#210).
324                let body = ctor_mx_doc
325                    .get(param_name)
326                    .map(String::as_str)
327                    .unwrap_or("(no documentation available)");
328                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    #\' @param {0} {1}",
                param_name, body))
    })format!("    #' @param {} {}", param_name, body));
329            }
330        }
331
332        // Precondition checks for constructor parameters
333        let ctor_preconditions = ctx.precondition_checks();
334
335        // Missing param prelude for constructor
336
337        let ctor_match_arg = ctx.match_arg_prelude();
338
339        let full_params = if ctx.params.is_empty() {
340            ".ptr = NULL".to_string()
341        } else {
342            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}, .ptr = NULL", ctx.params))
    })format!("{}, .ptr = NULL", ctx.params)
343        };
344        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    initialize = function({0}) {{",
                full_params))
    })format!("    initialize = function({}) {{", full_params));
345        // Preconditions + match.arg only when not using .ptr shortcut
346        if !ctor_preconditions.is_empty() || !ctor_match_arg.is_empty() {
347            lines.push("      if (is.null(.ptr)) {".to_string());
348            for check in &ctor_preconditions {
349                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("        {0}", check))
    })format!("        {}", check));
350            }
351            for line in &ctor_match_arg {
352                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("        {0}", line))
    })format!("        {}", line));
353            }
354            lines.push("      }".to_string());
355        }
356        lines.push("      if (!is.null(.ptr)) {".to_string());
357        lines.push("        private$.ptr <- .ptr".to_string());
358        lines.push("      } else {".to_string());
359        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("        .val <- {0}",
                ctx.static_call()))
    })format!("        .val <- {}", ctx.static_call()));
360        // Use shared condition switch (supports error!/warning!/message!/condition!).
361        for check_line in crate::method_return_builder::condition_check_lines("        ") {
362            lines.push(check_line);
363        }
364        lines.push("        private$.ptr <- .val".to_string());
365        lines.push("      }".to_string());
366        // initialize is the sole `public = list(...)` member — every other public
367        // method migrated to a `$set("public", ...)` block below — so no trailing comma.
368        lines.push("    }".to_string());
369    } else {
370        // No constructor. The factories and cross-class returns still wrap
371        // their pointer with `$new(.ptr = val)`; without one, `$new()` raises
372        // an error rather than hand out an object without a pointer.
373        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    #\' @description `{0}` has no constructor: `$new()` only wraps the `.ptr` its factory functions pass.",
                class_name))
    })format!(
374            "    #' @description `{class_name}` has no constructor: `$new()` only wraps \
375             the `.ptr` its factory functions pass."
376        ));
377        lines.push("    initialize = function(.ptr = NULL) {".to_string());
378        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("      {0}",
                super::no_constructor_guard(&class_name)))
    })format!(
379            "      {}",
380            super::no_constructor_guard(&class_name)
381        ));
382        lines.push("      private$.ptr <- .ptr".to_string());
383        lines.push("    }".to_string());
384    }
385
386    lines.push("  ),".to_string());
387
388    // Private list: the `.ptr` field. The private methods, `finalize` and
389    // `deep_clone` are `$set("private", ...)` members below.
390    lines.push("  private = list(".to_string());
391    lines.push("    .ptr = NULL".to_string());
392    lines.push("  ),".to_string());
393
394    // Class options
395    let lock_objects = parsed_impl.r6_lock_objects.unwrap_or(true);
396    let lock_class = parsed_impl.r6_lock_class.unwrap_or(false);
397    let cloneable = parsed_impl.r6_cloneable.unwrap_or(false);
398    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  lock_objects = {0},",
                if lock_objects { "TRUE" } else { "FALSE" }))
    })format!(
399        "  lock_objects = {},",
400        if lock_objects { "TRUE" } else { "FALSE" }
401    ));
402    // The class is always CREATED unlocked so the `$set()` blocks below (public
403    // methods, active bindings, and any `#[derive(ExternalPtr)]` sidecar
404    // accessors) can add to it — R6's `$set` refuses to touch a locked class
405    // ("Can't modify a locked R6 class"). When the user requested
406    // `lock_class = TRUE` we re-apply the lock via `ClassName$lock()` after every
407    // `$set` has run (see the end of this function); the net effect is identical
408    // to a class born locked, since the unlocked window is entirely within
409    // package-load evaluation.
410    lines.push("  lock_class = FALSE,".to_string());
411    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  cloneable = {0}",
                if cloneable { "TRUE" } else { "FALSE" }))
    })format!(
412        "  cloneable = {}",
413        if cloneable { "TRUE" } else { "FALSE" }
414    ));
415    lines.push(")".to_string());
416    w.head = std::mem::take(&mut lines);
417
418    // Private members — the private methods, `finalize` and `deep_clone` —
419    // one `$set("private", ...)` block each, so every impl block of the type
420    // adds its own to the class (#1776). No roxygen block: roxygen2 documents
421    // public members only.
422    for ctx in parsed_impl.private_instance_method_contexts() {
423        let r_name = ctx.method.r_method_name();
424        w.member_names.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("the R6 member `{0}`", r_name))
    })format!("the R6 member `{r_name}`"));
425        lines.push(String::new());
426        lines.push(ctx.source_comment(type_ident));
427        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}$set(\"private\", \"{1}\", function({2}) {{",
                class_name, r_name, ctx.params))
    })format!(
428            "{}$set(\"private\", \"{}\", function({}) {{",
429            class_name, r_name, ctx.params
430        ));
431
432        // The same prelude as a public method, so a private one keeps its
433        // lifecycle notice and its argument checks.
434        let what = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}${1}", class_name, r_name))
    })format!("{}${}", class_name, r_name);
435        ctx.emit_method_prelude(&mut lines, "  ", &what, &ctx.params);
436
437        let call = ctx.instance_call("private$.ptr");
438        let strategy = crate::ReturnStrategy::for_method(ctx.method);
439        let return_builder = crate::MethodReturnBuilder::new(call)
440            .with_strategy(strategy)
441            .with_invisible(ctx.method.is_invisible())
442            .with_class_name(class_name.clone())
443            .with_return_class_from_method(ctx.method, &type_ident.to_string())
444            .with_indent(2); // top-level `$set` closure body indents 2 spaces
445        lines.extend(return_builder.build_r6_body());
446
447        lines.push("})".to_string());
448    }
449
450    // Finalizer (if any)
451    if let Some(finalizer) = parsed_impl.finalizer() {
452        w.member_names.push("the R6 member `finalize`".to_string());
453        let c_ident = finalizer.c_wrapper_ident(type_ident).to_string();
454        let finalize_call = crate::r_wrapper_builder::DotCallBuilder::new(&c_ident)
455            .null_call_attribution()
456            .with_self("private$.ptr")
457            .build();
458        lines.push(String::new());
459        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}$set(\"private\", \"finalize\", function() {1})",
                class_name, finalize_call))
    })format!(
460            "{class_name}$set(\"private\", \"finalize\", function() {finalize_call})"
461        ));
462    }
463
464    // deep_clone (if any method marked with #[miniextendr(r6(deep_clone))])
465    if let Some(dc_method) = parsed_impl
466        .methods
467        .iter()
468        .find(|m| m.method_attrs.r6.deep_clone && m.should_include())
469    {
470        w.member_names
471            .push("the R6 member `deep_clone`".to_string());
472        let c_ident = dc_method.c_wrapper_ident(type_ident).to_string();
473        let deep_clone_call = crate::r_wrapper_builder::DotCallBuilder::new(&c_ident)
474            .null_call_attribution()
475            .with_self("private$.ptr")
476            .with_args(&["name", "value"])
477            .build();
478        lines.push(String::new());
479        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}$set(\"private\", \"deep_clone\", function(name, value) {1})",
                class_name, deep_clone_call))
    })format!(
480            "{class_name}$set(\"private\", \"deep_clone\", function(name, value) {deep_clone_call})"
481        ));
482    }
483
484    // Public instance methods — one `$set("public", ...)` block each, so roxygen2
485    // 8.0.0 documents every method from the block directly above its `$set` call
486    // (#369). No `overwrite = TRUE`: these are the class's own methods, and the
487    // default `overwrite = FALSE` catches accidental name collisions loudly.
488    let public_method_contexts: Vec<_> = parsed_impl.public_instance_method_contexts().collect();
489    for ctx in &public_method_contexts {
490        lines.push(String::new());
491        lines.push(ctx.source_comment(type_ident));
492        // Add roxygen documentation for this method.
493        // Note: @title is replaced with @description for R6 method docs (roxygen requirement)
494        let r_name = ctx.method.r_method_name();
495        w.member_names.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("the R6 member `{0}`", r_name))
    })format!("the R6 member `{r_name}`"));
496        let has_description = ctx
497            .method
498            .doc_tags
499            .iter()
500            .any(|t| t.starts_with("@description ") || t.starts_with("@title "));
501        if !has_description {
502            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @description Method `{0}`.",
                r_name))
    })format!("#' @description Method `{}`.", r_name));
503        }
504        // roxygen2 folds a `Class$set("public", ...)` block into the class
505        // block, so a method-level `@rdname` / `@name` here would make it
506        // "contain only one @rdname" and fail. R6 instance methods always
507        // document on the class page (static methods can still split, #1438).
508        for tag in &ctx.method.doc_tags {
509            if crate::roxygen::roxygen_tag_name(tag).is_some_and(|n| n == "rdname" || n == "name") {
510                continue;
511            }
512            for line in tag.lines() {
513                let line = if line.starts_with("@title ") {
514                    line.replacen("@title ", "@description ", 1)
515                } else {
516                    line.to_string()
517                };
518                lines.push(crate::roxygen::roxygen_line(&line));
519            }
520        }
521        // Document method params that aren't already documented.
522        // Class-level @param tags are inherited by roxygen2 8.0.0 — skip covered params.
523        // For subclass methods (r6_inherit set) and params not covered at class level,
524        // emit a write-time marker so the registry can detect in-package parent docs
525        // and suppress the placeholder (letting roxygen2 pull from the parent method).
526        let method_mx_doc = ctx.choice_param_docs();
527        let r_method_name = ctx.method.r_method_name();
528        for param in ctx.params.split(", ").filter(|p| !p.is_empty()) {
529            let param_name = param.split('=').next().unwrap_or(param).trim();
530            let already_documented =
531                crate::roxygen::param_documented(&ctx.method.doc_tags, param_name);
532            if !already_documented {
533                // Param covered by class-level @param → roxygen2 inherits; emit nothing.
534                if class_param_names.contains(param_name) {
535                    continue;
536                }
537                let body = method_mx_doc
538                    .get(param_name)
539                    .map(String::as_str)
540                    .unwrap_or("(no documentation available)");
541                // For subclass methods: if there is an in-package parent, emit a
542                // write-time marker. The registry pass will resolve it to nothing
543                // (parent documented → roxygen2 inherits) or to the fallback text.
544                if let Some(ref parent) = parsed_impl.r6_inherit
545                    && body == "(no documentation available)"
546                {
547                    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' {0}class=\"{1}\", parent=\"{2}\", method=\"{3}\", param=\"{4}\")",
                MX_INHERITED_PARAM_PREFIX, class_name, parent, r_method_name,
                param_name))
    })format!(
548                        "#' {}class=\"{}\", parent=\"{}\", method=\"{}\", param=\"{}\")",
549                        MX_INHERITED_PARAM_PREFIX, class_name, parent, r_method_name, param_name,
550                    ));
551                } else {
552                    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @param {0} {1}", param_name,
                body))
    })format!("#' @param {} {}", param_name, body));
553                }
554            }
555        }
556        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}$set(\"public\", \"{1}\", function({2}) {{",
                class_name, r_name, ctx.params))
    })format!(
557            "{}$set(\"public\", \"{}\", function({}) {{",
558            class_name, r_name, ctx.params
559        ));
560
561        let what = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}${1}", class_name, r_name))
    })format!("{}${}", class_name, r_name);
562        ctx.emit_method_prelude(&mut lines, "  ", &what, &ctx.params);
563
564        let call = ctx.instance_call("private$.ptr");
565        let strategy = crate::ReturnStrategy::for_method(ctx.method);
566        let return_builder = crate::MethodReturnBuilder::new(call)
567            .with_strategy(strategy)
568            .with_invisible(ctx.method.is_invisible())
569            .with_class_name(class_name.clone())
570            .with_return_class_from_method(ctx.method, &type_ident.to_string())
571            .with_indent(2); // top-level `$set` closure body indents 2 spaces
572        lines.extend(return_builder.build_r6_body());
573
574        lines.push("})".to_string());
575    }
576
577    // Active bindings. Their @field documentation is intentionally attached to
578    // the class block above; do not put a roxygen block before these dynamic
579    // `$set("active", ...)` calls (roxygen2 misclassifies it as method docs).
580    for ctx in &active_method_contexts {
581        lines.push(String::new());
582
583        // Determine the property name (from r6_prop or method name)
584        let prop_name = ctx
585            .method
586            .method_attrs
587            .r6
588            .prop
589            .clone()
590            .unwrap_or_else(|| ctx.method.r_method_name());
591        w.member_names.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("the R6 member `{0}`", prop_name))
    })format!("the R6 member `{prop_name}`"));
592
593        // Check if there's a matching setter for this property
594        let setter = parsed_impl.find_setter_for_prop(&prop_name);
595
596        if let Some(setter_method) = setter {
597            // Combined getter/setter active binding
598            // Format: name = function(value) { if (missing(value)) getter else setter }
599            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}$set(\"active\", \"{1}\", function(value) {{",
                class_name, prop_name))
    })format!(
600                "{}$set(\"active\", \"{}\", function(value) {{",
601                class_name, prop_name
602            ));
603            lines.push("  if (missing(value)) {".to_string());
604
605            // Getter call — same condition re-raise guard as the
606            // getter-only active binding path below.
607            let getter_call = ctx.instance_call("private$.ptr");
608            let getter_strategy = crate::ReturnStrategy::for_method(ctx.method);
609            let getter_builder = crate::MethodReturnBuilder::new(getter_call)
610                .with_strategy(getter_strategy)
611                .with_invisible(ctx.method.is_invisible())
612                .with_class_name(class_name.clone())
613                .with_return_class_from_method(ctx.method, &type_ident.to_string())
614                .with_indent(4);
615            lines.extend(getter_builder.build_r6_body());
616
617            lines.push("  } else {".to_string());
618
619            // Same precondition guards the standalone `set_*`
620            // method gets (audit 2026-07-06 finding 4): without it,
621            // `obj$prop <- <bad value>` skipped the R-level type check.
622            for check in active_setter_precondition_checks(setter_method, parsed_impl.preconditions)
623            {
624                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    {0}", check))
    })format!("    {}", check));
625            }
626
627            // Setter call — construct directly, then re-raise any
628            // transported Rust condition (the bare `.Call()` used to
629            // discard the tagged condition value, silently dropping the
630            // assignment error).
631            let setter_c_ident = setter_method.c_wrapper_ident(type_ident).to_string();
632            let setter_call = crate::r_wrapper_builder::DotCallBuilder::new(&setter_c_ident)
633                .with_self("private$.ptr")
634                .with_args(&["value"])
635                .build();
636            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    .val <- {0}", setter_call))
    })format!("    .val <- {}", setter_call));
637            lines.extend(crate::method_return_builder::condition_check_lines("    "));
638            // The generated assignment branch keeps its invisible default,
639            // while explicit method markers/options control direct binding calls.
640            let invisible = setter_method
641                .visibility_marker
642                .or(setter_method.method_attrs.force_invisible)
643                .unwrap_or(true);
644            lines.push(
645                if invisible {
646                    "    invisible(self)"
647                } else {
648                    "    self"
649                }
650                .to_string(),
651            );
652
653            lines.push("  }".to_string());
654            lines.push("})".to_string());
655        } else {
656            // Getter-only active binding (no parameters besides self)
657            // Format: name = function() { ... }
658            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}$set(\"active\", \"{1}\", function() {{",
                class_name, prop_name))
    })format!(
659                "{}$set(\"active\", \"{}\", function() {{",
660                class_name, prop_name
661            ));
662
663            let call = ctx.instance_call("private$.ptr");
664            let strategy = crate::ReturnStrategy::for_method(ctx.method);
665            let return_builder = crate::MethodReturnBuilder::new(call)
666                .with_strategy(strategy)
667                .with_invisible(ctx.method.is_invisible())
668                .with_class_name(class_name.clone())
669                .with_return_class_from_method(ctx.method, &type_ident.to_string())
670                .with_indent(2); // top-level `$set` closure body indents 2 spaces
671            lines.extend(return_builder.build_r6_body());
672
673            lines.push("})".to_string());
674        }
675    }
676
677    // Check if class has @noRd. A plain `noexport` (without `internal`) is folded
678    // in here too — it must suppress Rd contribution entirely, matching
679    // `ClassDocBuilder::build`'s `suppress_rd` gate (see r_class_formatter.rs).
680    let class_has_no_rd = crate::roxygen::has_roxygen_tag(class_doc_tags, "noRd")
681        || (parsed_impl.noexport && !parsed_impl.internal);
682
683    // Static methods as separate functions on the class object
684    for ctx in parsed_impl.static_method_contexts() {
685        let method_name = ctx.method.r_method_name();
686        w.member_names.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("`{0}${1}`", class_name,
                method_name))
    })format!("`{class_name}${method_name}`"));
687        // `$` takes an R symbol: quote operator names (#1475).
688        let static_method_name =
689            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}${1}", class_name,
                crate::naming::r_def_name(&method_name)))
    })format!("{}${}", class_name, crate::naming::r_def_name(&method_name));
690        lines.push(String::new());
691
692        lines.push(ctx.source_comment(type_ident));
693        let doc_tags = parsed_impl.static_doc_tags(
694            ctx.method,
695            &::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}${1}()", class_name,
                method_name))
    })format!("{class_name}${method_name}()"),
696            &::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}$new()", class_name))
    })format!("{class_name}$new()"),
697            true,
698        );
699        let method_doc = MethodDocBuilder::new(&class_name, &method_name, type_ident, &doc_tags)
700            .with_source_method(&ctx.method.ident)
701            .with_name_prefix("$")
702            .with_class_no_rd(class_has_no_rd);
703        lines.extend(method_doc.build());
704
705        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} <- function({1}) {{",
                static_method_name, ctx.params))
    })format!(
706            "{} <- function({}) {{",
707            static_method_name, ctx.params
708        ));
709
710        let what = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}${1}", class_name, method_name))
    })format!("{}${}", class_name, method_name);
711        ctx.emit_method_prelude(&mut lines, "  ", &what, &ctx.params);
712
713        let strategy = crate::ReturnStrategy::for_method(ctx.method);
714        let return_builder = crate::MethodReturnBuilder::new(ctx.static_call())
715            .with_strategy(strategy)
716            .with_invisible(ctx.method.is_invisible())
717            .with_class_name(class_name.clone())
718            .with_return_class_from_method(ctx.method, &type_ident.to_string());
719        lines.extend(return_builder.build_r6_body());
720
721        lines.push("}".to_string());
722    }
723    w.members = std::mem::take(&mut lines);
724
725    // If r_data_accessors is set, apply sidecar active bindings from #[derive(ExternalPtr)]
726    if parsed_impl.r_data_accessors.enabled() {
727        let type_name = type_ident.to_string();
728        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!(".rdata_active_bindings_{0}({1})",
                type_name, class_name))
    })format!(
729            ".rdata_active_bindings_{}({})",
730            type_name, class_name
731        ));
732    }
733
734    // Re-apply the requested `lock_class = TRUE` now that every `$set()` block —
735    // public methods, active bindings, and (above) any sidecar accessors — has
736    // run, in every impl block of the type. The class was created with
737    // `lock_class = FALSE` (see the class-options block) precisely so those
738    // `$set` calls would succeed; `$lock()` restores the locked semantics.
739    // Emitted last so nothing further can `$set` it.
740    if lock_class {
741        lines.push(String::new());
742        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}$lock()", class_name))
    })format!("{}$lock()", class_name));
743    }
744
745    w.tail = lines;
746    w
747}
748
749/// The R6 class as one string: [`generate_r6_class`], rendered.
750#[cfg(test)]
751pub fn generate_r6_r_wrapper(parsed_impl: &ParsedImpl) -> String {
752    generate_r6_class(parsed_impl).render()
753}