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}