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}