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