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