Skip to main content

miniextendr_macros/miniextendr_impl/
s7_class.rs

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