miniextendr_macros/miniextendr_impl/s7_class.rs
1//! S7-class R wrapper generator.
2//!
3//! Generates `S7::new_class(...)` with **value semantics, formal property
4//! validation, and parent-based inheritance**. Property attributes
5//! (`s7(getter)` / `s7(setter, prop = "...")`) compile to `S7::new_property`
6//! with `@`-access semantics, including read-only computed properties and
7//! read-write dynamic ones. Similar formal power to S4 with cleaner syntax,
8//! but the S7 ecosystem is younger and the R-package dependency surface is
9//! evolving. Pick S7 for **new** packages wanting modern formal OOP; use S4
10//! when you need Bioconductor compatibility or multi-dispatch.
11
12use super::{ParsedImpl, ParsedMethod};
13use crate::r_class_formatter::{class_ref_or_verbatim, is_bare_identifier};
14
15/// Extract the full property documentation from a getter's `doc_tags` for use in
16/// `@prop <name> <doc>` emission.
17///
18/// A getter's leading prose is promoted to `@description` (see
19/// `roxygen::leading_prose_from_attrs`); a getter's `@title` would only ever be a
20/// hand-written explicit tag. This helper collects any `@title`, `@description`, and
21/// `@details` present and joins them with `\n\n` for multi-line `@prop` continuation
22/// (supported by roxygen2 8.0.0+).
23fn extract_prop_doc_from_tags(doc_tags: &[String]) -> Option<String> {
24 let mut parts: Vec<String> = Vec::new();
25
26 // Look for @title (primary doc — from para 1)
27 if let Some(title) = doc_tags.iter().find_map(|t| {
28 if !t.starts_with('@') {
29 return Some(t.clone()); // plain text before any @tag
30 }
31 t.strip_prefix("@title ").map(|s| s.to_string())
32 }) {
33 parts.push(title);
34 }
35
36 // Look for @description (para 2)
37 if let Some(desc) = doc_tags
38 .iter()
39 .find_map(|t| t.strip_prefix("@description ").map(|s| s.to_string()))
40 {
41 parts.push(desc);
42 }
43
44 // Look for @details (para 3+)
45 if let Some(details) = doc_tags
46 .iter()
47 .find_map(|t| t.strip_prefix("@details ").map(|s| s.to_string()))
48 {
49 parts.push(details);
50 }
51
52 if parts.is_empty() {
53 None
54 } else {
55 Some(parts.join("\n\n"))
56 }
57}
58
59/// S7-property variant of [`class_ref_or_verbatim`] that asks the resolver to
60/// fall back silently to `S7::class_any` on miss (unregistered type, or a
61/// registered-but-non-S7 class). Prevents the load-time `object not found`
62/// noise called out in #203.
63fn class_ref_or_any_or_verbatim(name: &str) -> String {
64 if is_bare_identifier(name) {
65 format!(".__MX_CLASS_REF_OR_ANY_{name}__")
66 } else {
67 name.to_string()
68 }
69}
70
71/// Roxygen prose for the fast-path dispatch shortcut advisory block.
72///
73/// Shared between the inherent-impl S7 generator ([`generate_s7_r_wrapper`])
74/// and the trait-impl S7 generator
75/// (`miniextendr_impl_trait::r_wrappers::generate_trait_s7_r_wrapper`) so both
76/// emit identical guidance. Returns `#'`-prefixed lines (no trailing `@param`
77/// / `@name` scaffolding — the caller appends those).
78pub(crate) fn shortcut_advisory_lines(method_name: &str, class_name: &str) -> Vec<String> {
79 vec![
80 format!(
81 "#' Fast-path shortcut for the `{}` S7 method on `{}`.",
82 method_name, class_name
83 ),
84 "#'".to_string(),
85 "#' Calls the underlying Rust routine directly, bypassing `S7::S7_dispatch()`".to_string(),
86 "#' (the class walk + method-table lookup). Use in hot loops where the".to_string(),
87 "#' per-call dispatch overhead matters. **Footgun:** this shortcut does not".to_string(),
88 "#' perform subclass dispatch \u{2014} a method override defined on a child class"
89 .to_string(),
90 "#' will *not* be honoured. Use the generic when subclassing is possible.".to_string(),
91 ]
92}
93
94/// Map a Rust return type to an S7 class name.
95///
96/// Returns `None` if the type doesn't map to any S7 class — the caller
97/// then omits the `class = …` constraint (so S7 uses `class_any`).
98///
99/// # S7 Class Mapping
100///
101/// | Rust Type | S7 Class |
102/// |-----------|----------|
103/// | `i32`, `i16`, `i8` | `class_integer` |
104/// | `f64`, `f32` | `class_double` |
105/// | `bool` | `class_logical` |
106/// | `u8` | `class_raw` |
107/// | `String`, `&str` | `class_character` |
108/// | `Vec<i32>` | `class_integer` |
109/// | `Vec<f64>` | `class_double` |
110/// | `Vec<bool>` | `class_logical` |
111/// | `Vec<String>` | `class_character` |
112/// | `Option<T>` | `NULL | class_T` (union) |
113/// | `SomeUserType` (bare ident) | `.__MX_CLASS_REF_SomeUserType__` |
114///
115/// For bare user-defined path types the macro emits the same
116/// `.__MX_CLASS_REF_<Type>__` placeholder used for parent-class and
117/// `convert_from`/`convert_to` references (see [`#154`]). The resolver in
118/// `miniextendr_api::registry::write_r_wrappers_to_file` swaps the
119/// placeholder for the R-visible class name recorded in `MX_CLASS_NAMES`
120/// — so `class = "Override"` on an S7 impl block is honored, and child
121/// class properties tighten from `class_any` to the real class.
122///
123/// Unresolved types fall through the existing CLASS_REF mechanism:
124/// the bare Rust name is emitted with a compile-time warning. If the
125/// user returns a type that isn't a registered class at all, they'll
126/// see that warning + a `object '…' not found` at R load time.
127pub(super) fn rust_type_to_s7_class(ty: &syn::Type) -> Option<String> {
128 match ty {
129 syn::Type::Path(type_path) => {
130 let seg = type_path.path.segments.last()?;
131 let ident = seg.ident.to_string();
132
133 match ident.as_str() {
134 // Scalar types
135 "i32" | "i16" | "i8" | "isize" => Some("S7::class_integer".to_string()),
136 "f64" | "f32" => Some("S7::class_double".to_string()),
137 "bool" => Some("S7::class_logical".to_string()),
138 "u8" => Some("S7::class_raw".to_string()),
139 "String" => Some("S7::class_character".to_string()),
140
141 // Vec types - check inner type
142 "Vec" => {
143 if let syn::PathArguments::AngleBracketed(args) = &seg.arguments
144 && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
145 {
146 // Recursively get the inner type's class
147 return rust_type_to_s7_class(inner);
148 }
149 None
150 }
151
152 // Option types - create union with NULL
153 "Option" => {
154 if let syn::PathArguments::AngleBracketed(args) = &seg.arguments
155 && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
156 && let Some(inner_class) = rust_type_to_s7_class(inner)
157 {
158 return Some(format!("NULL | {}", inner_class));
159 }
160 None
161 }
162
163 // Result types - use the Ok type
164 "Result" => {
165 if let syn::PathArguments::AngleBracketed(args) = &seg.arguments
166 && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
167 {
168 return rust_type_to_s7_class(inner);
169 }
170 None
171 }
172
173 // Bare, un-generic single-segment identifier — reuse the
174 // CLASS_REF write-time placeholder so the resolver swaps
175 // in the registered R-visible class name (honoring any
176 // `class = "Override"` on the referenced impl block).
177 // Paths with `::`, generics, or a lowercase leading char
178 // are rejected to avoid matching crate-local aliases,
179 // primitives, or type parameters — those fall through
180 // to `None` and the caller omits the `class =` entirely.
181 _ if type_path.path.segments.len() == 1
182 && matches!(seg.arguments, syn::PathArguments::None)
183 && is_bare_identifier(&ident)
184 && ident.chars().next().is_some_and(|c| c.is_ascii_uppercase()) =>
185 {
186 // S7 property class constraint: use the OR_ANY variant so
187 // an unregistered type (or a registered-but-non-S7 class)
188 // falls back silently to `class_any`, restoring pre-#154
189 // behavior for those edge cases (#203).
190 Some(class_ref_or_any_or_verbatim(&ident))
191 }
192
193 _ => None,
194 }
195 }
196 syn::Type::Reference(type_ref) => {
197 // Handle &str
198 if let syn::Type::Path(type_path) = type_ref.elem.as_ref()
199 && let Some(seg) = type_path.path.segments.last()
200 && seg.ident == "str"
201 {
202 return Some("S7::class_character".to_string());
203 }
204 // Recurse for other reference types
205 rust_type_to_s7_class(&type_ref.elem)
206 }
207 _ => None,
208 }
209}
210
211/// Generates the complete R wrapper string for an S7-style class.
212///
213/// Produces the following R code:
214/// - Class definition: `ClassName <- S7::new_class("ClassName", ...)` with a `.ptr` property
215/// of `class_any` holding the `ExternalPtr`, plus optional computed properties
216/// - Constructor: inline in `new_class(constructor = function(...) ...)`, supports
217/// `.ptr` shortcut parameter for wrapping pre-built ExternalPtr returns
218/// - Properties: `S7::new_property(...)` for each getter/setter/validator annotated
219/// with `#[miniextendr(s7(getter))]` etc., with support for class constraints,
220/// defaults, required, frozen, and deprecated modifiers
221/// - Instance methods: `S7::new_generic(...)` + `S7::method(generic, class)` pairs
222/// dispatching to Rust `.Call()` wrappers via `x@.ptr`
223/// - External generics: `S7::new_external_generic("pkg", "name")` for overriding
224/// generics from other packages
225/// - Multiple dispatch: via `#[miniextendr(s7(dispatch = "x,y"))]`
226/// - Fallback methods: `S7::method(generic, S7::class_any)` with `tryCatch` for
227/// safe slot access on non-S7 objects
228/// - Static methods: regular functions named `ClassName_method(...)`
229/// - Convert methods: `S7::method(convert, list(From, To))` for `convert_from`
230/// and `convert_to` annotations
231/// - S7 parent/abstract: optional `parent` and `abstract = TRUE` in class definition
232///
233/// Roxygen2 documentation and `@importFrom S7 ...` tags are generated automatically.
234pub fn generate_s7_r_wrapper(parsed_impl: &ParsedImpl) -> String {
235 use crate::r_class_formatter::{
236 ClassDocBuilder, MethodContext, MethodDocBuilder, ParsedImplExt, should_export_from_tags,
237 };
238
239 let class_name = parsed_impl.class_name();
240 let type_ident = &parsed_impl.type_ident;
241 let class_doc_tags = &parsed_impl.doc_tags;
242 // Check if class has @noRd - if so, skip method documentation and exports. A
243 // plain `noexport` (without `internal`) is folded in too — it must suppress
244 // Rd contribution entirely, matching `ClassDocBuilder::build`'s `suppress_rd`
245 // gate. `should_export` (below) already independently gates @export/@exportMethod.
246 let class_has_no_rd = crate::roxygen::has_roxygen_tag(class_doc_tags, "noRd")
247 || (parsed_impl.noexport && !parsed_impl.internal);
248 let should_export =
249 should_export_from_tags(class_doc_tags, parsed_impl.noexport || parsed_impl.internal);
250
251 let mut lines = Vec::new();
252
253 // Collect S7 property getters, setters, and validators
254 // Property name is: s7_prop if specified, else method name
255 // We store method idents so we can look them up later
256 /// Accumulated metadata for a single S7 property, built up from getter,
257 /// setter, and validator method annotations during the first pass over methods.
258 struct S7Property {
259 /// Property name (from `#[miniextendr(s7(prop = "..."))]` or the method ident).
260 name: String,
261 /// Ident of the method annotated with `#[miniextendr(s7(getter))]`.
262 getter_method_ident: Option<String>,
263 /// Ident of the method annotated with `#[miniextendr(s7(setter))]`.
264 setter_method_ident: Option<String>,
265 /// Ident of the method annotated with `#[miniextendr(s7(validate))]`.
266 validator_method_ident: Option<String>,
267 /// S7 class type inferred from the getter's return type (e.g., `"S7::class_double"`).
268 class_type: Option<String>,
269 /// Default value as an R expression string (from `#[miniextendr(s7(default = "..."))]`).
270 default_value: Option<String>,
271 /// When `true`, the property errors if not provided during construction.
272 required: bool,
273 /// When `true`, the property can only be set once (subsequent sets error).
274 frozen: bool,
275 /// If set, a deprecation warning is emitted when the property is accessed or set.
276 deprecated: Option<String>,
277 /// Documentation extracted from the getter method's first doc line.
278 /// Used to emit `#' @prop name doc` in the class-level roxygen block.
279 doc: Option<String>,
280 }
281
282 let mut properties: std::collections::BTreeMap<String, S7Property> =
283 std::collections::BTreeMap::new();
284 let mut property_method_idents: std::collections::HashSet<String> =
285 std::collections::HashSet::new();
286
287 // First pass: collect all property methods (getters, setters, validators)
288 for method in &parsed_impl.methods {
289 if !method.should_include() {
290 continue;
291 }
292 let attrs = &method.method_attrs;
293
294 if attrs.s7.getter || attrs.s7.setter || attrs.s7.validate {
295 let method_ident = method.ident.to_string();
296 let prop_name = attrs
297 .s7
298 .prop
299 .clone()
300 .unwrap_or_else(|| method_ident.clone());
301
302 property_method_idents.insert(method_ident.clone());
303
304 let entry = properties.entry(prop_name.clone()).or_insert(S7Property {
305 name: prop_name,
306 getter_method_ident: None,
307 setter_method_ident: None,
308 validator_method_ident: None,
309 class_type: None,
310 default_value: None,
311 required: false,
312 frozen: false,
313 deprecated: None,
314 doc: None,
315 });
316
317 if attrs.s7.getter {
318 entry.getter_method_ident = Some(method_ident.clone());
319 // Extract S7 class type from getter's return type
320 if let syn::ReturnType::Type(_, ret_type) = &method.sig.output {
321 entry.class_type = rust_type_to_s7_class(ret_type);
322 }
323 // Capture property attributes from getter
324 if let Some(ref default) = attrs.s7.default {
325 entry.default_value = Some(default.clone());
326 }
327 if attrs.s7.required {
328 entry.required = true;
329 }
330 if attrs.s7.frozen {
331 entry.frozen = true;
332 }
333 if let Some(ref msg) = attrs.s7.deprecated {
334 entry.deprecated = Some(msg.clone());
335 }
336 // Extract full doc from getter's doc comment for @prop documentation
337 // in the class-level roxygen block. After #578 the auto_description
338 // path emits @title (para 1), @description (para 2), @details (para 3+).
339 // We collect all three to surface the complete doc under @prop.
340 // Multi-line @prop is supported by roxygen2 8.0.0 (continuation lines
341 // indented with two spaces).
342 entry.doc = extract_prop_doc_from_tags(&method.doc_tags);
343 }
344 if attrs.s7.setter {
345 entry.setter_method_ident = Some(method_ident.clone());
346 }
347 if attrs.s7.validate {
348 entry.validator_method_ident = Some(method_ident);
349 }
350 }
351 }
352
353 // Helper to find method by ident
354 let find_method = |ident: &str| -> Option<&ParsedMethod> {
355 parsed_impl.methods.iter().find(|m| m.ident == ident)
356 };
357
358 // Determine imports based on whether we have properties and what class types are used
359 let base_imports = "new_class class_any new_object S7_object new_generic method";
360 let mut import_parts: Vec<&str> = vec![base_imports];
361
362 if !properties.is_empty() {
363 import_parts.push("new_property");
364 }
365
366 // Check if any methods use S7 convert (convert_from or convert_to)
367 let has_convert_methods = parsed_impl.methods.iter().any(|m| {
368 m.should_include()
369 && (m.method_attrs.s7.convert_from.is_some() || m.method_attrs.s7.convert_to.is_some())
370 });
371 if has_convert_methods {
372 import_parts.push("convert");
373 }
374
375 // Collect unique S7 class types used in properties
376 let mut class_imports: std::collections::HashSet<&str> = std::collections::HashSet::new();
377 for prop in properties.values() {
378 if let Some(ref class_type) = prop.class_type {
379 // Extract class name from "S7::class_xxx" or "NULL | S7::class_xxx"
380 for part in class_type.split('|') {
381 let part = part.trim();
382 if let Some(class_name) = part.strip_prefix("S7::") {
383 class_imports.insert(class_name);
384 }
385 }
386 }
387 }
388 // Sort for deterministic output
389 let mut sorted_imports: Vec<&str> = class_imports.into_iter().collect();
390 sorted_imports.sort();
391 for class_name in sorted_imports {
392 import_parts.push(class_name);
393 }
394
395 let imports = format!("@importFrom S7 {}", import_parts.join(" "));
396
397 // Class definition with documentation
398 lines.extend(
399 ClassDocBuilder::new(&class_name, type_ident, class_doc_tags, "S7")
400 .with_imports(&imports)
401 .with_export_control(parsed_impl.internal, parsed_impl.noexport)
402 .build(),
403 );
404 // Inject lifecycle imports from methods into class-level roxygen block
405 if let Some(lc_import) = crate::lifecycle::collect_lifecycle_imports(
406 parsed_impl
407 .methods
408 .iter()
409 .filter_map(|m| m.method_attrs.lifecycle.as_ref()),
410 ) {
411 let insert_pos = lines.len().saturating_sub(1);
412 lines.insert(insert_pos, format!("#' {}", lc_import));
413 }
414
415 // Document constructor params — include constructor @param tags and auto-generate
416 // for undocumented ones. Class-level @param tags are already emitted by ClassDocBuilder;
417 // constructor-level @param tags must be explicitly pushed here since S7 inlines the
418 // constructor inside new_class().
419 // Skip if class has @noRd
420 if !class_has_no_rd {
421 if let Some(ctx) = parsed_impl.constructor_context() {
422 let mx_doc = ctx.match_arg_doc_placeholders();
423 for param in ctx.params.split(", ").filter(|p| !p.is_empty()) {
424 let param_name = param.split('=').next().unwrap_or(param).trim();
425 if param_name == ".ptr" || param_name == "..." {
426 continue;
427 }
428 // Check if already documented at the class level (impl block doc_tags)
429 let in_class_docs = crate::roxygen::param_documented(class_doc_tags, param_name);
430 if in_class_docs {
431 continue; // Already emitted by ClassDocBuilder
432 }
433 // Check if documented in the constructor method's doc_tags
434 let ctor_tag = crate::roxygen::find_param_tag(&ctx.method.doc_tags, param_name);
435 if let Some(tag) = ctor_tag {
436 lines.push(format!("#' {}", tag));
437 } else if let Some(placeholder) = mx_doc.get(param_name) {
438 // match_arg'd constructor param — placeholder rewritten at
439 // cdylib write time to rendered choice description (#210).
440 lines.push(format!("#' @param {} {}", param_name, placeholder));
441 } else {
442 lines.push(format!("#' @param {} (undocumented)", param_name));
443 }
444 }
445 }
446 // .ptr is always a constructor param
447 if !crate::roxygen::has_roxygen_tag(class_doc_tags, "param .ptr") {
448 lines.push(
449 "#' @param .ptr Internal pointer (used by static methods, not for direct use)."
450 .to_string(),
451 );
452 }
453
454 // @prop tags for impl-block S7 properties (getter/setter pairs).
455 // These appear in the class-level @rdname page via roxygen2's @prop tag.
456 //
457 // Properties that ARE constructor formals are already documented above via
458 // @param and must NOT also receive @prop — roxygen2 8.0.0 treats @param /
459 // @prop as disjoint sets (constructor formals → @param; getter/setter-only
460 // props → @prop). Collect the constructor-formal names and skip them here.
461 let constructor_param_names: std::collections::HashSet<String> =
462 if let Some(ctx) = parsed_impl.constructor_context() {
463 ctx.params
464 .split(", ")
465 .filter(|p| !p.is_empty())
466 .map(|p| p.split('=').next().unwrap_or(p).trim().to_string())
467 .filter(|n| n != ".ptr" && n != "...")
468 .collect()
469 } else {
470 std::collections::HashSet::new()
471 };
472 for prop in properties.values() {
473 if constructor_param_names.contains(&prop.name) {
474 continue; // already documented as @param above
475 }
476 let doc = prop.doc.as_deref().unwrap_or("(undocumented property)");
477 // Emit @prop with multi-line continuation support (roxygen2 8.0.0+).
478 // First line: `#' @prop <name> <para1>`.
479 // Additional paragraphs: `#' <continuation>` (two-space indent).
480 let mut prop_lines = doc.lines();
481 if let Some(first_line) = prop_lines.next() {
482 lines.push(format!("#' @prop {} {}", prop.name, first_line));
483 for continuation in prop_lines {
484 lines.push(format!("#' {}", continuation));
485 }
486 } else {
487 lines.push(format!("#' @prop {} {}", prop.name, doc));
488 }
489 }
490
491 // @prop tags for sidecar (r_data_accessors) properties.
492 // Emitted via a write-time placeholder that MX_S7_SIDECAR_PROPS resolves.
493 // The placeholder is replaced in `write_r_wrappers_to_file` with one
494 // `#' @prop field doc` line per registered sidecar field.
495 if parsed_impl.r_data_accessors {
496 let type_name = type_ident.to_string();
497 lines.push(format!(".__MX_S7_SIDECAR_PROP_DOCS_{type_name}__"));
498 }
499 }
500
501 // S7::new_class — optionally include parent and abstract
502 if let Some(ref parent) = parsed_impl.s7_parent {
503 // Use a placeholder so the resolver can look up the actual R class name
504 // at cdylib write time (handles `class = "Override"` on the parent).
505 let parent_ref = class_ref_or_verbatim(parent);
506 lines.push(format!(
507 "{} <- S7::new_class(\"{}\", parent = {},",
508 class_name, class_name, parent_ref
509 ));
510 } else {
511 lines.push(format!(
512 "{} <- S7::new_class(\"{}\",",
513 class_name, class_name
514 ));
515 }
516
517 if parsed_impl.s7_abstract {
518 lines.push(" abstract = TRUE,".to_string());
519 }
520
521 // Properties - .ptr holds the ExternalPtr, plus computed/dynamic properties
522 // When r_data_accessors is set, merge with sidecar properties from #[derive(ExternalPtr)]
523 // Collect property items into a vec, then join with ",\n" to avoid bare commas on standalone lines
524 let mut prop_items: Vec<String> = Vec::new();
525 prop_items.push(" .ptr = S7::class_any".to_string());
526
527 // Generate computed/dynamic properties
528 for prop in properties.values() {
529 // Generate property definition
530 let mut prop_parts = Vec::new();
531
532 // Add class constraint if known (inferred from getter return type)
533 if let Some(ref class_type) = prop.class_type {
534 prop_parts.push(format!("class = {}", class_type));
535 }
536
537 // Handle default value or required pattern
538 if prop.required {
539 // Required pattern: error if not provided
540 prop_parts.push(format!(
541 "default = quote(stop(\"@{} is required\"))",
542 prop.name
543 ));
544 } else if let Some(ref default) = prop.default_value {
545 // Explicit default value (R expression)
546 prop_parts.push(format!("default = {}", default));
547 }
548
549 // Add validator if present
550 if let Some(ref validator_ident) = prop.validator_method_ident
551 && let Some(validator_method) = find_method(validator_ident)
552 {
553 let ctx = MethodContext::new(validator_method, type_ident, parsed_impl.label());
554 // Validator is called with just the value, not self.
555 // Use null_call_attribution: this runs inside S7's dispatch lambda, so
556 // match.call() would capture S7 internals, not the user's call site.
557 let validator_call = crate::r_wrapper_builder::DotCallBuilder::new(&ctx.c_ident)
558 .null_call_attribution()
559 .with_args(&["value"])
560 .build();
561 prop_parts.push(format!("validator = function(value) {validator_call}"));
562 }
563
564 // Generate getter (with optional deprecation warning)
565 if let Some(ref getter_ident) = prop.getter_method_ident
566 && let Some(getter_method) = find_method(getter_ident)
567 {
568 let ctx = MethodContext::new(getter_method, type_ident, parsed_impl.label());
569 // Use null_call_attribution: runs inside S7's property dispatch lambda.
570 let getter_call = ctx.instance_call_null_attr("self@.ptr");
571 if let Some(ref msg) = prop.deprecated {
572 // Deprecated getter: emit warning then return value
573 prop_parts.push(format!(
574 "getter = function(self) {{ warning(\"Property @{} is deprecated: {}\"); {} }}",
575 prop.name, msg, getter_call
576 ));
577 } else {
578 prop_parts.push(format!("getter = function(self) {}", getter_call));
579 }
580 }
581
582 // Generate setter (with optional frozen/deprecation handling)
583 if let Some(ref setter_ident) = prop.setter_method_ident
584 && let Some(setter_method) = find_method(setter_ident)
585 {
586 let ctx = MethodContext::new(setter_method, type_ident, parsed_impl.label());
587 // Use null_call_attribution: runs inside S7's property dispatch lambda.
588 let setter_call = ctx.instance_call_null_attr("self@.ptr");
589
590 if prop.frozen {
591 // Frozen pattern: error if property was already set (non-NULL)
592 // Note: This is a simplified check; true frozen behavior would need
593 // a separate flag in the object to track if ever set
594 if let Some(ref msg) = prop.deprecated {
595 prop_parts.push(format!(
596 "setter = function(self, value) {{ warning(\"Property @{} is deprecated: {}\"); if (!is.null(self@{})) stop(\"Property @{} is frozen and cannot be modified\"); {}; self }}",
597 prop.name, msg, prop.name, prop.name, setter_call
598 ));
599 } else {
600 prop_parts.push(format!(
601 "setter = function(self, value) {{ if (!is.null(self@{})) stop(\"Property @{} is frozen and cannot be modified\"); {}; self }}",
602 prop.name, prop.name, setter_call
603 ));
604 }
605 } else if let Some(ref msg) = prop.deprecated {
606 // Deprecated setter: emit warning then set value
607 prop_parts.push(format!(
608 "setter = function(self, value) {{ warning(\"Property @{} is deprecated: {}\"); {}; self }}",
609 prop.name, msg, setter_call
610 ));
611 } else {
612 // Normal setter
613 prop_parts.push(format!(
614 "setter = function(self, value) {{ {}; self }}",
615 setter_call
616 ));
617 }
618 }
619
620 if prop_parts.is_empty() {
621 // This shouldn't happen, but handle gracefully
622 prop_items.push(format!(" {} = S7::new_property()", prop.name));
623 } else {
624 prop_items.push(format!(
625 " {} = S7::new_property({})",
626 prop.name,
627 prop_parts.join(", ")
628 ));
629 }
630 }
631
632 if parsed_impl.r_data_accessors {
633 lines.push(" properties = c(list(".to_string());
634 } else {
635 lines.push(" properties = list(".to_string());
636 }
637 lines.push(prop_items.join(",\n"));
638
639 // Close the properties list (or merge with sidecar properties)
640 if parsed_impl.r_data_accessors {
641 let type_name = type_ident.to_string();
642 lines.push(format!(" ), .rdata_properties_{}),", type_name));
643 } else {
644 lines.push(" ),".to_string());
645 }
646
647 if let Some(ctx) = parsed_impl.constructor_context() {
648 let ctor_preconditions = ctx.precondition_checks();
649 let ctor_match_arg = ctx.match_arg_prelude();
650 let params_with_ptr = if ctx.params.is_empty() {
651 ".ptr = NULL".to_string()
652 } else {
653 format!("{}, .ptr = NULL", ctx.params)
654 };
655 lines.push(format!(" constructor = function({}) {{", params_with_ptr));
656 // Preconditions + match.arg only when not using .ptr shortcut
657 if !ctor_preconditions.is_empty() || !ctor_match_arg.is_empty() {
658 lines.push(" if (is.null(.ptr)) {".to_string());
659 for check in &ctor_preconditions {
660 lines.push(format!(" {}", check));
661 }
662 for line in &ctor_match_arg {
663 lines.push(format!(" {}", line));
664 }
665 lines.push(" }".to_string());
666 }
667 lines.push(" if (!is.null(.ptr)) {".to_string());
668 lines.push(" S7::new_object(S7::S7_object(), .ptr = .ptr)".to_string());
669 lines.push(" } else {".to_string());
670 lines.push(format!(" .val <- {}", ctx.static_call()));
671 lines.extend(crate::method_return_builder::condition_check_lines(
672 " ",
673 ));
674 lines.push(" S7::new_object(S7::S7_object(), .ptr = .val)".to_string());
675 lines.push(" }".to_string());
676 lines.push(" }".to_string());
677 } else {
678 lines.push(" constructor = function(.ptr = NULL) {".to_string());
679 lines.push(" S7::new_object(S7::S7_object(), .ptr = .ptr)".to_string());
680 lines.push(" }".to_string());
681 }
682
683 lines.push(")".to_string());
684 lines.push(String::new());
685
686 // Instance methods as S7 generics + methods
687 // Skip methods that are property getters/setters (they're handled as S7 properties)
688 for ctx in parsed_impl.instance_method_contexts() {
689 let method_ident = ctx.method.ident.to_string();
690 if property_method_idents.contains(&method_ident) {
691 continue;
692 }
693
694 let generic_name = ctx.generic_name();
695 let full_params = ctx.instance_formals(true); // adds x, ..., params
696 let method_attrs = &ctx.method.method_attrs;
697
698 // Emit the generic-doc marker BEFORE the source comment so that the
699 // write-time pass can place the standalone Rd page before the method block.
700 // This ensures the synthesised doc block (ending with NULL) is always
701 // separated from the method's roxygen comment by the source `# comment` line
702 // — preventing roxygen2 from merging the two blocks into one.
703 //
704 // Only package-owned (non-external, non-override) generics get a marker;
705 // fallback methods dispatch on S7::class_any and don't define a new generic.
706 if !ctx.has_generic_override() && !method_attrs.s7.fallback && !class_has_no_rd {
707 let dispatch_str = method_attrs
708 .s7
709 .dispatch
710 .as_deref()
711 .unwrap_or("x")
712 .replace(' ', "");
713 let no_dots_str = if method_attrs.s7.no_dots {
714 "true"
715 } else {
716 "false"
717 };
718 lines.push(format!(
719 ".__MX_GENERIC_DOC__(kind=\"S7\", generic=\"{generic_name}\", class=\"{class_name}\", export={should_export}, dispatch=\"{dispatch_str}\", no_dots={no_dots_str})"
720 ));
721 }
722
723 lines.push(ctx.source_comment(type_ident));
724
725 // For fallback methods (class_any), check class before using @ to extract
726 // the pointer. Non-S7 objects can't have @.ptr — error in R rather than
727 // passing a wrong type to Rust (which would segfault).
728 let self_expr = if method_attrs.s7.fallback {
729 "if (inherits(x, \"S7_object\")) x@.ptr else stop(paste0(\"expected an S7 object, got \", class(x)[[1]]))"
730 } else {
731 "x@.ptr"
732 };
733 let call = ctx.instance_call(self_expr);
734
735 // Determine dispatch class (fallback -> class_any, normal -> class_name)
736 let method_class = if method_attrs.s7.fallback {
737 "S7::class_any".to_string()
738 } else {
739 class_name.clone()
740 };
741
742 // Documentation - skip if class has @noRd.
743 // Use class-qualified @name to avoid duplicate \alias{generic} warnings
744 // when multiple S7 classes share the same generic (e.g., get_value on both
745 // S7TraitCounter and CounterTraitS7). The @export is replaced with
746 // @rawNamespace to explicitly export the bare generic name.
747 if !class_has_no_rd {
748 let qualified_name = format!("{}-{}", class_name, generic_name);
749 let method_doc =
750 MethodDocBuilder::new(&class_name, &generic_name, type_ident, &ctx.method.doc_tags)
751 .with_suppress_params()
752 .with_r_name(qualified_name);
753 let mut doc_lines = method_doc.build();
754 doc_lines.push(format!("#' @aliases {}${}", class_name, generic_name));
755 lines.extend(doc_lines);
756 }
757
758 if ctx.has_generic_override() {
759 // Parse "pkg::name" format for external generics
760 let (pkg, gen_name) = if generic_name.contains("::") {
761 let parts: Vec<&str> = generic_name.split("::").collect();
762 (parts[0].to_string(), parts[1].to_string())
763 } else {
764 ("base".to_string(), generic_name.clone())
765 };
766
767 // Use S7::new_external_generic for existing generics from other packages
768 lines.push(format!(
769 "if (!exists(\"{gen_name}\", mode = \"function\")) {{"
770 ));
771 lines.push(format!(
772 " {gen_name} <- S7::new_external_generic(\"{pkg}\", \"{gen_name}\")"
773 ));
774 lines.push("}".to_string());
775
776 // Define method using the resolved generic name
777 let strategy = crate::ReturnStrategy::for_method(ctx.method);
778 let body_lines = crate::MethodReturnBuilder::new(call.clone())
779 .with_strategy(strategy)
780 .with_class_name(class_name.clone())
781 .with_return_class_from_method(ctx.method)
782 .build_s7_body();
783
784 let what = format!("{}.{}", generic_name, class_name);
785 lines.push(format!(
786 "S7::method({gen_name}, {method_class}) <- function({full_params}) {{"
787 ));
788 ctx.emit_method_prelude(&mut lines, " ", &what);
789 lines.extend(body_lines);
790 lines.push("}".to_string());
791 } else {
792 // Create new S7 generic if it doesn't exist
793 // Use @rawNamespace to explicitly export the bare generic name.
794 // Plain @export would export the qualified @name (e.g., "ClassName-method")
795 // instead of the bare generic.
796 if should_export {
797 lines.push(format!("#' @rawNamespace export({})", generic_name));
798 }
799
800 // Determine dispatch arguments (default: "x", or custom via dispatch = "x,y")
801 let dispatch_args = if let Some(ref dispatch) = method_attrs.s7.dispatch {
802 // Multiple dispatch: "x,y" -> c("x", "y")
803 let args: Vec<&str> = dispatch.split(',').map(|s| s.trim()).collect();
804 if args.len() == 1 {
805 format!("\"{}\"", args[0])
806 } else {
807 format!(
808 "c({})",
809 args.iter()
810 .map(|a| format!("\"{}\"", a))
811 .collect::<Vec<_>>()
812 .join(", ")
813 )
814 }
815 } else {
816 "\"x\"".to_string()
817 };
818
819 // Determine function signature (with or without ...)
820 let generic_sig = if method_attrs.s7.no_dots {
821 // no_dots: strict generic without ...
822 if let Some(ref dispatch) = method_attrs.s7.dispatch {
823 let args: Vec<&str> = dispatch.split(',').map(|s| s.trim()).collect();
824 format!("function({}) S7::S7_dispatch()", args.join(", "))
825 } else {
826 "function(x) S7::S7_dispatch()".to_string()
827 }
828 } else {
829 // Default: include ... for extra args
830 if let Some(ref dispatch) = method_attrs.s7.dispatch {
831 let args: Vec<&str> = dispatch.split(',').map(|s| s.trim()).collect();
832 format!("function({}, ...) S7::S7_dispatch()", args.join(", "))
833 } else {
834 "function(x, ...) S7::S7_dispatch()".to_string()
835 }
836 };
837
838 // Classify any existing binding of `generic_name` (#1114). A bare
839 // `exists(...)` check is wrong: if the name resolves to a *plain*
840 // base/stats closure (var, get, row, col, diag, reshape, ...),
841 // `S7::method(<closure>, ...) <-` errors at load ("generic is a
842 // function, but not an S3 generic function") and the package fails
843 // to install. Only create/reuse when the existing binding is
844 // something S7::method<- accepts as a generic: an S7 generic, a
845 // primitive, an S3 standard generic, or an S4 generic. Otherwise
846 // shadow it with a package-local S7 generic.
847 let dispatch_arg_names: Vec<String> =
848 if let Some(ref dispatch) = method_attrs.s7.dispatch {
849 dispatch.split(',').map(|s| s.trim().to_string()).collect()
850 } else {
851 vec!["x".to_string()]
852 };
853 // Fallback signature/forwarding args mirror the generic's formals so
854 // the class_any method S7 registers is signature-compatible.
855 let fallback_sig = {
856 let mut sig = dispatch_arg_names.join(", ");
857 if !method_attrs.s7.no_dots {
858 if sig.is_empty() {
859 sig.push_str("...");
860 } else {
861 sig.push_str(", ...");
862 }
863 }
864 sig
865 };
866 let class_any_spec = if dispatch_arg_names.len() > 1 {
867 format!(
868 "list({})",
869 vec!["S7::class_any"; dispatch_arg_names.len()].join(", ")
870 )
871 } else {
872 "S7::class_any".to_string()
873 };
874
875 // Emit an `if (!base::exists(...))` / `else if ({classifier})` chain
876 // rather than a top-level `.mx_gen <- ...` assignment. The leading
877 // statement must NOT be an assignment: roxygen2 documents the first
878 // top-level binding after the doc block, and a `.mx_gen <- ...` there
879 // pollutes every S7 man page with a bogus \alias{.mx_gen}/\usage. The
880 // `.mx_gen` binding used by the classifier lives inside the braced
881 // `else if` condition, which roxygen2 does not descend into.
882 //
883 // `base::exists`/`base::get`: once we define a shadow generic named
884 // e.g. `get`, a bare `get(...)` in a later generic's classifier would
885 // route through our own generic. Qualify to stay robust.
886 lines.push(format!(
887 "if (!base::exists(\"{generic_name}\", mode = \"function\")) {{"
888 ));
889 lines.push(
890 " # No existing binding; define a plain package-local S7 generic.".to_string(),
891 );
892 lines.push(format!(
893 " {generic_name} <- S7::new_generic(\"{generic_name}\", {dispatch_args}, {generic_sig})"
894 ));
895 lines.push(format!(
896 "}} else if (local({{ .mx_gen <- base::get(\"{generic_name}\", mode = \"function\"); !(inherits(.mx_gen, \"S7_generic\") || is.primitive(.mx_gen) || isTRUE(utils::isS3stdGeneric(.mx_gen)) || methods::isGeneric(\"{generic_name}\")) }})) {{"
897 ));
898 lines.push(format!(
899 " # `{generic_name}` resolves to a plain (non-generic) function S7 cannot"
900 ));
901 lines.push(
902 " # attach a method to. Define a package-local S7 generic that shadows it,"
903 .to_string(),
904 );
905 lines.push(
906 " # with a class_any fallback delegating to the masked function so ordinary"
907 .to_string(),
908 );
909 lines.push(
910 " # (non-S7) calls (e.g. var(1:10)) keep working. S4 generics also take this"
911 .to_string(),
912 );
913 lines.push(" # path; the fallback preserves their dispatch.".to_string());
914 // `local()` gives the fallback closure its own environment holding an
915 // eagerly-assigned `.mx_masked` (assignment forces the value now — a
916 // function *argument* would stay an unforced promise and later see the
917 // reused `.mx_gen`, which is why we don't pass it as one).
918 lines.push(format!(" {generic_name} <- local({{"));
919 lines.push(format!(
920 " .mx_masked <- base::get(\"{generic_name}\", mode = \"function\")"
921 ));
922 lines.push(format!(
923 " .mx_g <- S7::new_generic(\"{generic_name}\", {dispatch_args}, {generic_sig})"
924 ));
925 lines.push(format!(
926 " S7::method(.mx_g, {class_any_spec}) <- function({fallback_sig}) .mx_masked({fallback_sig})"
927 ));
928 lines.push(" .mx_g".to_string());
929 lines.push(" })".to_string());
930 lines.push("}".to_string());
931 lines.push(
932 "# else: existing usable generic (S7/primitive/S3/S4) — reuse as-is.".to_string(),
933 );
934
935 // Define method
936 let strategy = crate::ReturnStrategy::for_method(ctx.method);
937 let body_lines = crate::MethodReturnBuilder::new(call)
938 .with_strategy(strategy)
939 .with_class_name(class_name.clone())
940 .with_return_class_from_method(ctx.method)
941 .build_s7_body();
942
943 // Use matching formals for method (with or without ...)
944 let method_formals = ctx.instance_formals_with_dots(true, !method_attrs.s7.no_dots);
945
946 let what = format!("{}.{}", generic_name, class_name);
947 lines.push(format!(
948 "S7::method({generic_name}, {method_class}) <- function({method_formals}) {{"
949 ));
950 ctx.emit_method_prelude(&mut lines, " ", &what);
951 lines.extend(body_lines);
952 lines.push("}".to_string());
953 }
954 lines.push(String::new());
955
956 // Per-class fast-path dispatch shortcut (#949).
957 //
958 // Alongside the S7 generic, emit a plain non-generic function
959 // `<ClassName>_<method_name>(self, ...)` whose body is identical to the
960 // generic's method but calls `.Call` directly — bypassing
961 // `S7::S7_dispatch()` (class-walk + method-table lookup). On hot loops
962 // this is several times faster than the generic. The receiver is named
963 // `self` here (the generic names it `x`) and is wired through `self@.ptr`.
964 //
965 // Fallback methods dispatch on `S7::class_any`, so a per-class shortcut
966 // is meaningless — skip them. `s7(no_shortcut)` opts a method out
967 // explicitly (e.g. to avoid a name collision with a sidecar accessor).
968 if !method_attrs.s7.fallback && !method_attrs.s7.no_shortcut {
969 let method_name = ctx.method.r_method_name();
970 let shortcut_name = format!("{}_{}", class_name, method_name);
971 let shortcut_call = ctx.instance_call("self@.ptr");
972 let shortcut_formals =
973 ctx.instance_formals_with_receiver("self", !method_attrs.s7.no_dots);
974
975 // Document the shortcut as a standalone function merged onto the
976 // class @rdname page (same shape as static methods). Prepend a
977 // fast-path advisory describing the dispatch bypass and the
978 // subclass-override footgun, then reuse MethodDocBuilder for the
979 // @name / @rdname / @source / @param scaffolding so roxygen2 emits a
980 // complete \usage + \arguments block (no "undocumented argument"
981 // warning). `@param self` is documented explicitly because
982 // MethodDocBuilder skips the receiver.
983 if !class_has_no_rd {
984 lines.extend(shortcut_advisory_lines(&method_name, &class_name));
985 lines.push(format!("#' @param self A `{}` object.", class_name));
986 // `...` appears in the shortcut signature for parity with the S7
987 // generic but MethodDocBuilder doesn't document it, so emit it
988 // here to avoid an "Undocumented arguments" R CMD check warning.
989 if !method_attrs.s7.no_dots {
990 lines.push(
991 "#' @param ... Additional arguments; ignored by the fast-path shortcut."
992 .to_string(),
993 );
994 }
995 // Pass empty doc_tags: the method's own prose (@title/description)
996 // is already rendered on the shared @rdname page by the generic
997 // block above; re-emitting it here would duplicate it. We only
998 // need the @name / @rdname / @source / @param scaffolding so the
999 // shortcut's \usage is fully documented.
1000 let mx_doc = ctx.match_arg_doc_placeholders();
1001 let no_tags: [String; 0] = [];
1002 let method_doc =
1003 MethodDocBuilder::new(&class_name, &method_name, type_ident, &no_tags)
1004 .with_r_params(&shortcut_formals)
1005 .with_match_arg_doc_placeholders(&mx_doc)
1006 .with_r_name(shortcut_name.clone());
1007 lines.extend(method_doc.build());
1008 }
1009 // Export the shortcut when the class is exported, so users can reach it.
1010 if should_export {
1011 lines.push(format!("#' @export {}", shortcut_name));
1012 }
1013
1014 let strategy = crate::ReturnStrategy::for_method(ctx.method);
1015 let shortcut_body = crate::MethodReturnBuilder::new(shortcut_call)
1016 .with_strategy(strategy)
1017 .with_class_name(class_name.clone())
1018 .with_return_class_from_method(ctx.method)
1019 .with_chain_var("self".to_string())
1020 .build_s7_body();
1021
1022 let what = format!("{}.{}", generic_name, class_name);
1023 lines.push(format!(
1024 "{shortcut_name} <- function({shortcut_formals}) {{"
1025 ));
1026 ctx.emit_method_prelude(&mut lines, " ", &what);
1027 lines.extend(shortcut_body);
1028 lines.push("}".to_string());
1029 lines.push(String::new());
1030 }
1031 }
1032
1033 // Static methods as regular functions
1034 for ctx in parsed_impl.static_method_contexts() {
1035 lines.push(ctx.source_comment(type_ident));
1036 let method_name = ctx.method.r_method_name();
1037 let fn_name = format!("{}_{}", class_name, method_name);
1038
1039 // Skip documentation if class has @noRd
1040 if !class_has_no_rd {
1041 let mx_doc = ctx.match_arg_doc_placeholders();
1042 let method_doc =
1043 MethodDocBuilder::new(&class_name, &method_name, type_ident, &ctx.method.doc_tags)
1044 .with_r_params(&ctx.params)
1045 .with_match_arg_doc_placeholders(&mx_doc)
1046 .with_r_name(fn_name.clone());
1047 lines.extend(method_doc.build());
1048 }
1049 // Export static methods so users can call them (if class should be exported)
1050 if should_export {
1051 lines.push("#' @export".to_string());
1052 }
1053
1054 lines.push(format!("{} <- function({}) {{", fn_name, ctx.params));
1055
1056 ctx.emit_method_prelude(&mut lines, " ", &fn_name);
1057
1058 let strategy = crate::ReturnStrategy::for_method(ctx.method);
1059 let return_expr = crate::MethodReturnBuilder::new(ctx.static_call())
1060 .with_strategy(strategy)
1061 .with_class_name(class_name.clone())
1062 .with_return_class_from_method(ctx.method)
1063 .build_s7_inline();
1064 lines.push(format!(" {}", return_expr));
1065
1066 lines.push("}".to_string());
1067 lines.push(String::new());
1068 }
1069
1070 // Phase 4: S7 convert() methods from Rust From/TryFrom patterns
1071 // Convert methods enable type coercion between S7 classes using S7::convert()
1072 //
1073 // Two patterns:
1074 // 1. convert_from = "OtherType" on static method: converts FROM OtherType TO this class
1075 // Rust: fn from_other(other: OtherType) -> Self
1076 // R: S7::method(S7::convert, list(OtherType, ThisClass)) <- function(from, to) ...
1077 //
1078 // 2. convert_to = "OtherType" on instance method: converts FROM this class TO OtherType
1079 // Rust: fn to_other(&self) -> OtherType
1080 // R: S7::method(S7::convert, list(ThisClass, OtherType)) <- function(from, to) ...
1081
1082 for method in &parsed_impl.methods {
1083 if !method.should_include() {
1084 continue;
1085 }
1086 let attrs = &method.method_attrs;
1087
1088 // Handle convert_from (static method pattern)
1089 // S7 convert signature is function(from, to) - one parameter for the source object
1090 if let Some(ref from_type) = attrs.s7.convert_from {
1091 let ctx = MethodContext::new(method, type_ident, parsed_impl.label()).with_fast_flags(
1092 parsed_impl.no_preconditions,
1093 parsed_impl.no_call_attribution,
1094 );
1095
1096 // Documentation for convert method (skip if class has @noRd)
1097 if !class_has_no_rd {
1098 lines.push(format!("#' @name convert-{}-to-{}", from_type, class_name));
1099 lines.push(format!("#' @rdname {}", class_name));
1100 lines.push(crate::roxygen::method_source_tag(type_ident, &method.ident));
1101 // Add @aliases convert so roxygen2 emits \alias{convert} in the
1102 // merged .Rd file. Without this, R CMD check warns:
1103 // "Objects in \usage without \alias in Rd file '...Rd': 'convert'"
1104 lines.push("#' @aliases convert".to_string());
1105 // S7's `convert` generic is `function(from, to, ...)`. Document
1106 // `...` so the rendered \usage{} matches and codoc passes.
1107 lines.push(
1108 "#' @param ... Additional arguments passed to the S7 convert generic."
1109 .to_string(),
1110 );
1111 }
1112
1113 // Generate: S7::method(S7::convert, list(FromType, ThisClass)) <- function(from, to, ...) ...
1114 // The convert_from method takes the source object as its sole parameter
1115 // We pass from@.ptr to extract the ExternalPtr from the S7 object
1116 let call_with_from = crate::r_wrapper_builder::DotCallBuilder::new(&ctx.c_ident)
1117 .with_self("from@.ptr")
1118 .build();
1119
1120 let strategy = crate::ReturnStrategy::for_method(method);
1121 let return_expr = crate::MethodReturnBuilder::new(call_with_from)
1122 .with_strategy(strategy)
1123 .with_class_name(class_name.clone())
1124 .with_return_class_from_method(method)
1125 .build_s7_inline();
1126
1127 // Use imported `convert` - requires `@importFrom S7 convert` in package.
1128 // from_type is a cross-reference → placeholder so the resolver can look it up.
1129 // Method signature includes `...` to match the S7 `convert` generic
1130 // (function(from, to, ...)) and silence R CMD check codoc warnings.
1131 let from_type_ref = class_ref_or_verbatim(from_type);
1132 lines.push(format!(
1133 "S7::method(convert, list({}, {})) <- function(from, to, ...) {}",
1134 from_type_ref, class_name, return_expr
1135 ));
1136 lines.push(String::new());
1137 }
1138
1139 // Handle convert_to (instance method pattern)
1140 // S7 convert signature is function(from, to) - self becomes from
1141 if let Some(ref to_type) = attrs.s7.convert_to {
1142 let ctx = MethodContext::new(method, type_ident, parsed_impl.label()).with_fast_flags(
1143 parsed_impl.no_preconditions,
1144 parsed_impl.no_call_attribution,
1145 );
1146
1147 // Documentation for convert method (skip if class has @noRd)
1148 if !class_has_no_rd {
1149 lines.push(format!("#' @name convert-{}-to-{}", class_name, to_type));
1150 lines.push(format!("#' @rdname {}", class_name));
1151 lines.push(crate::roxygen::method_source_tag(type_ident, &method.ident));
1152 // Add @aliases convert so roxygen2 emits \alias{convert} in the
1153 // merged .Rd file. Without this, R CMD check warns:
1154 // "Objects in \usage without \alias in Rd file '...Rd': 'convert'"
1155 lines.push("#' @aliases convert".to_string());
1156 // S7's `convert` generic is `function(from, to, ...)`. Document
1157 // `...` so the rendered \usage{} matches and codoc passes.
1158 lines.push(
1159 "#' @param ... Additional arguments passed to the S7 convert generic."
1160 .to_string(),
1161 );
1162 }
1163
1164 // Generate: S7::method(convert, list(ThisClass, ToType)) <- function(from, to, ...) ...
1165 // The convert_to method is an instance method where self is mapped to from@.ptr
1166 let call = crate::r_wrapper_builder::DotCallBuilder::new(&ctx.c_ident)
1167 .with_self("from@.ptr")
1168 .build();
1169
1170 // to_type is a cross-reference → placeholder for resolver.
1171 // We also pass the placeholder to MethodReturnBuilder so the
1172 // emitted `ToType(.ptr = <result>)` uses the resolved name.
1173 let to_type_ref = class_ref_or_verbatim(to_type);
1174
1175 // Force ReturnSelf strategy for convert methods since they return S7 class types
1176 // that need to be wrapped: ToType(.ptr = <result>)
1177 let return_expr = crate::MethodReturnBuilder::new(call)
1178 .with_strategy(crate::ReturnStrategy::ReturnSelf)
1179 .with_class_name(to_type_ref.clone())
1180 .build_s7_inline();
1181
1182 // Use imported `convert` - requires `@importFrom S7 convert` in package.
1183 // Method signature includes `...` to match the S7 `convert` generic
1184 // (function(from, to, ...)) and silence R CMD check codoc warnings.
1185 lines.push(format!(
1186 "S7::method(convert, list({}, {})) <- function(from, to, ...) {}",
1187 class_name, to_type_ref, return_expr
1188 ));
1189 lines.push(String::new());
1190 }
1191 }
1192
1193 lines.join("\n")
1194}