Skip to main content

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}