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 factory methods returning `Self`
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    // Constructor - check if .ptr param will be added (for static methods returning Self)
359    let has_self_returning_methods = parsed_impl
360        .methods
361        .iter()
362        .filter(|m| m.should_include())
363        .any(|m| m.returns_self());
364
365    // Determine imports based on whether we have properties and what class types are used
366    let base_imports = "new_class class_any new_object S7_object new_generic method";
367    let mut import_parts: Vec<&str> = vec![base_imports];
368
369    if !properties.is_empty() {
370        import_parts.push("new_property");
371    }
372
373    // Check if any methods use S7 convert (convert_from or convert_to)
374    let has_convert_methods = parsed_impl.methods.iter().any(|m| {
375        m.should_include()
376            && (m.method_attrs.s7.convert_from.is_some() || m.method_attrs.s7.convert_to.is_some())
377    });
378    if has_convert_methods {
379        import_parts.push("convert");
380    }
381
382    // Collect unique S7 class types used in properties
383    let mut class_imports: std::collections::HashSet<&str> = std::collections::HashSet::new();
384    for prop in properties.values() {
385        if let Some(ref class_type) = prop.class_type {
386            // Extract class name from "S7::class_xxx" or "NULL | S7::class_xxx"
387            for part in class_type.split('|') {
388                let part = part.trim();
389                if let Some(class_name) = part.strip_prefix("S7::") {
390                    class_imports.insert(class_name);
391                }
392            }
393        }
394    }
395    // Sort for deterministic output
396    let mut sorted_imports: Vec<&str> = class_imports.into_iter().collect();
397    sorted_imports.sort();
398    for class_name in sorted_imports {
399        import_parts.push(class_name);
400    }
401
402    let imports = format!("@importFrom S7 {}", import_parts.join(" "));
403
404    // Class definition with documentation
405    lines.extend(
406        ClassDocBuilder::new(&class_name, type_ident, class_doc_tags, "S7")
407            .with_imports(&imports)
408            .with_export_control(parsed_impl.internal, parsed_impl.noexport)
409            .build(),
410    );
411    // Inject lifecycle imports from methods into class-level roxygen block
412    if let Some(lc_import) = crate::lifecycle::collect_lifecycle_imports(
413        parsed_impl
414            .methods
415            .iter()
416            .filter_map(|m| m.method_attrs.lifecycle.as_ref()),
417    ) {
418        let insert_pos = lines.len().saturating_sub(1);
419        lines.insert(insert_pos, format!("#' {}", lc_import));
420    }
421
422    // Document constructor params — include constructor @param tags and auto-generate
423    // for undocumented ones. Class-level @param tags are already emitted by ClassDocBuilder;
424    // constructor-level @param tags must be explicitly pushed here since S7 inlines the
425    // constructor inside new_class().
426    // Skip if class has @noRd
427    if !class_has_no_rd {
428        if let Some(ctx) = parsed_impl.constructor_context() {
429            let mx_doc = ctx.match_arg_doc_placeholders();
430            for param in ctx.params.split(", ").filter(|p| !p.is_empty()) {
431                let param_name = param.split('=').next().unwrap_or(param).trim();
432                if param_name == ".ptr" || param_name == "..." {
433                    continue;
434                }
435                // Check if already documented at the class level (impl block doc_tags)
436                let in_class_docs = class_doc_tags
437                    .iter()
438                    .any(|t| t.starts_with(&format!("@param {}", param_name)));
439                if in_class_docs {
440                    continue; // Already emitted by ClassDocBuilder
441                }
442                // Check if documented in the constructor method's doc_tags
443                let ctor_tag = ctx
444                    .method
445                    .doc_tags
446                    .iter()
447                    .find(|t| t.starts_with(&format!("@param {}", param_name)));
448                if let Some(tag) = ctor_tag {
449                    lines.push(format!("#' {}", tag));
450                } else if let Some(placeholder) = mx_doc.get(param_name) {
451                    // match_arg'd constructor param — placeholder rewritten at
452                    // cdylib write time to rendered choice description (#210).
453                    lines.push(format!("#' @param {} {}", param_name, placeholder));
454                } else {
455                    lines.push(format!("#' @param {} (undocumented)", param_name));
456                }
457            }
458        }
459        // .ptr is always a constructor param
460        if !crate::roxygen::has_roxygen_tag(class_doc_tags, "param .ptr") {
461            lines.push(
462                "#' @param .ptr Internal pointer (used by static methods, not for direct use)."
463                    .to_string(),
464            );
465        }
466
467        // @prop tags for impl-block S7 properties (getter/setter pairs).
468        // These appear in the class-level @rdname page via roxygen2's @prop tag.
469        //
470        // Properties that ARE constructor formals are already documented above via
471        // @param and must NOT also receive @prop — roxygen2 8.0.0 treats @param /
472        // @prop as disjoint sets (constructor formals → @param; getter/setter-only
473        // props → @prop). Collect the constructor-formal names and skip them here.
474        let constructor_param_names: std::collections::HashSet<String> =
475            if let Some(ctx) = parsed_impl.constructor_context() {
476                ctx.params
477                    .split(", ")
478                    .filter(|p| !p.is_empty())
479                    .map(|p| p.split('=').next().unwrap_or(p).trim().to_string())
480                    .filter(|n| n != ".ptr" && n != "...")
481                    .collect()
482            } else {
483                std::collections::HashSet::new()
484            };
485        for prop in properties.values() {
486            if constructor_param_names.contains(&prop.name) {
487                continue; // already documented as @param above
488            }
489            let doc = prop.doc.as_deref().unwrap_or("(undocumented property)");
490            // Emit @prop with multi-line continuation support (roxygen2 8.0.0+).
491            // First line: `#' @prop <name> <para1>`.
492            // Additional paragraphs: `#'   <continuation>` (two-space indent).
493            let mut prop_lines = doc.lines();
494            if let Some(first_line) = prop_lines.next() {
495                lines.push(format!("#' @prop {} {}", prop.name, first_line));
496                for continuation in prop_lines {
497                    lines.push(format!("#'   {}", continuation));
498                }
499            } else {
500                lines.push(format!("#' @prop {} {}", prop.name, doc));
501            }
502        }
503
504        // @prop tags for sidecar (r_data_accessors) properties.
505        // Emitted via a write-time placeholder that MX_S7_SIDECAR_PROPS resolves.
506        // The placeholder is replaced in `write_r_wrappers_to_file` with one
507        // `#' @prop field doc` line per registered sidecar field.
508        if parsed_impl.r_data_accessors {
509            let type_name = type_ident.to_string();
510            lines.push(format!(".__MX_S7_SIDECAR_PROP_DOCS_{type_name}__"));
511        }
512    }
513
514    // S7::new_class — optionally include parent and abstract
515    if let Some(ref parent) = parsed_impl.s7_parent {
516        // Use a placeholder so the resolver can look up the actual R class name
517        // at cdylib write time (handles `class = "Override"` on the parent).
518        let parent_ref = class_ref_or_verbatim(parent);
519        lines.push(format!(
520            "{} <- S7::new_class(\"{}\", parent = {},",
521            class_name, class_name, parent_ref
522        ));
523    } else {
524        lines.push(format!(
525            "{} <- S7::new_class(\"{}\",",
526            class_name, class_name
527        ));
528    }
529
530    if parsed_impl.s7_abstract {
531        lines.push("  abstract = TRUE,".to_string());
532    }
533
534    // Properties - .ptr holds the ExternalPtr, plus computed/dynamic properties
535    // When r_data_accessors is set, merge with sidecar properties from #[derive(ExternalPtr)]
536    // Collect property items into a vec, then join with ",\n" to avoid bare commas on standalone lines
537    let mut prop_items: Vec<String> = Vec::new();
538    prop_items.push("    .ptr = S7::class_any".to_string());
539
540    // Generate computed/dynamic properties
541    for prop in properties.values() {
542        // Generate property definition
543        let mut prop_parts = Vec::new();
544
545        // Add class constraint if known (inferred from getter return type)
546        if let Some(ref class_type) = prop.class_type {
547            prop_parts.push(format!("class = {}", class_type));
548        }
549
550        // Handle default value or required pattern
551        if prop.required {
552            // Required pattern: error if not provided
553            prop_parts.push(format!(
554                "default = quote(stop(\"@{} is required\"))",
555                prop.name
556            ));
557        } else if let Some(ref default) = prop.default_value {
558            // Explicit default value (R expression)
559            prop_parts.push(format!("default = {}", default));
560        }
561
562        // Add validator if present
563        if let Some(ref validator_ident) = prop.validator_method_ident
564            && let Some(validator_method) = find_method(validator_ident)
565        {
566            let ctx = MethodContext::new(validator_method, type_ident, parsed_impl.label());
567            // Validator is called with just the value, not self.
568            // Use null_call_attribution: this runs inside S7's dispatch lambda, so
569            // match.call() would capture S7 internals, not the user's call site.
570            let validator_call = crate::r_wrapper_builder::DotCallBuilder::new(&ctx.c_ident)
571                .null_call_attribution()
572                .with_args(&["value"])
573                .build();
574            prop_parts.push(format!("validator = function(value) {validator_call}"));
575        }
576
577        // Generate getter (with optional deprecation warning)
578        if let Some(ref getter_ident) = prop.getter_method_ident
579            && let Some(getter_method) = find_method(getter_ident)
580        {
581            let ctx = MethodContext::new(getter_method, type_ident, parsed_impl.label());
582            // Use null_call_attribution: runs inside S7's property dispatch lambda.
583            let getter_call = ctx.instance_call_null_attr("self@.ptr");
584            if let Some(ref msg) = prop.deprecated {
585                // Deprecated getter: emit warning then return value
586                prop_parts.push(format!(
587                    "getter = function(self) {{ warning(\"Property @{} is deprecated: {}\"); {} }}",
588                    prop.name, msg, getter_call
589                ));
590            } else {
591                prop_parts.push(format!("getter = function(self) {}", getter_call));
592            }
593        }
594
595        // Generate setter (with optional frozen/deprecation handling)
596        if let Some(ref setter_ident) = prop.setter_method_ident
597            && let Some(setter_method) = find_method(setter_ident)
598        {
599            let ctx = MethodContext::new(setter_method, type_ident, parsed_impl.label());
600            // Use null_call_attribution: runs inside S7's property dispatch lambda.
601            let setter_call = ctx.instance_call_null_attr("self@.ptr");
602
603            if prop.frozen {
604                // Frozen pattern: error if property was already set (non-NULL)
605                // Note: This is a simplified check; true frozen behavior would need
606                // a separate flag in the object to track if ever set
607                if let Some(ref msg) = prop.deprecated {
608                    prop_parts.push(format!(
609                        "setter = function(self, value) {{ warning(\"Property @{} is deprecated: {}\"); if (!is.null(self@{})) stop(\"Property @{} is frozen and cannot be modified\"); {}; self }}",
610                        prop.name, msg, prop.name, prop.name, setter_call
611                    ));
612                } else {
613                    prop_parts.push(format!(
614                        "setter = function(self, value) {{ if (!is.null(self@{})) stop(\"Property @{} is frozen and cannot be modified\"); {}; self }}",
615                        prop.name, prop.name, setter_call
616                    ));
617                }
618            } else if let Some(ref msg) = prop.deprecated {
619                // Deprecated setter: emit warning then set value
620                prop_parts.push(format!(
621                    "setter = function(self, value) {{ warning(\"Property @{} is deprecated: {}\"); {}; self }}",
622                    prop.name, msg, setter_call
623                ));
624            } else {
625                // Normal setter
626                prop_parts.push(format!(
627                    "setter = function(self, value) {{ {}; self }}",
628                    setter_call
629                ));
630            }
631        }
632
633        if prop_parts.is_empty() {
634            // This shouldn't happen, but handle gracefully
635            prop_items.push(format!("    {} = S7::new_property()", prop.name));
636        } else {
637            prop_items.push(format!(
638                "    {} = S7::new_property({})",
639                prop.name,
640                prop_parts.join(", ")
641            ));
642        }
643    }
644
645    if parsed_impl.r_data_accessors {
646        lines.push("  properties = c(list(".to_string());
647    } else {
648        lines.push("  properties = list(".to_string());
649    }
650    lines.push(prop_items.join(",\n"));
651
652    // Close the properties list (or merge with sidecar properties)
653    if parsed_impl.r_data_accessors {
654        let type_name = type_ident.to_string();
655        lines.push(format!("  ), .rdata_properties_{}),", type_name));
656    } else {
657        lines.push("  ),".to_string());
658    }
659
660    if let Some(ctx) = parsed_impl.constructor_context() {
661        let ctor_preconditions = ctx.precondition_checks();
662        let ctor_match_arg = ctx.match_arg_prelude();
663        if has_self_returning_methods {
664            let params_with_ptr = if ctx.params.is_empty() {
665                ".ptr = NULL".to_string()
666            } else {
667                format!("{}, .ptr = NULL", ctx.params)
668            };
669            lines.push(format!("  constructor = function({}) {{", params_with_ptr));
670            // Preconditions + match.arg only when not using .ptr shortcut
671            if !ctor_preconditions.is_empty() || !ctor_match_arg.is_empty() {
672                lines.push("    if (is.null(.ptr)) {".to_string());
673                for check in &ctor_preconditions {
674                    lines.push(format!("      {}", check));
675                }
676                for line in &ctor_match_arg {
677                    lines.push(format!("      {}", line));
678                }
679                lines.push("    }".to_string());
680            }
681            lines.push("    if (!is.null(.ptr)) {".to_string());
682            lines.push("      S7::new_object(S7::S7_object(), .ptr = .ptr)".to_string());
683            lines.push("    } else {".to_string());
684            lines.push(format!("      .val <- {}", ctx.static_call()));
685            lines.extend(crate::method_return_builder::condition_check_lines(
686                "      ",
687            ));
688            lines.push("      S7::new_object(S7::S7_object(), .ptr = .val)".to_string());
689            lines.push("    }".to_string());
690            lines.push("  }".to_string());
691        } else {
692            lines.push(format!("  constructor = function({}) {{", ctx.params));
693            for check in &ctor_preconditions {
694                lines.push(format!("    {}", check));
695            }
696            for line in &ctor_match_arg {
697                lines.push(format!("    {}", line));
698            }
699            lines.push(format!("    .val <- {}", ctx.static_call()));
700            lines.extend(crate::method_return_builder::condition_check_lines("    "));
701            lines.push("    S7::new_object(S7::S7_object(), .ptr = .val)".to_string());
702            lines.push("  }".to_string());
703        }
704    }
705
706    lines.push(")".to_string());
707    lines.push(String::new());
708
709    // Instance methods as S7 generics + methods
710    // Skip methods that are property getters/setters (they're handled as S7 properties)
711    for ctx in parsed_impl.instance_method_contexts() {
712        let method_ident = ctx.method.ident.to_string();
713        if property_method_idents.contains(&method_ident) {
714            continue;
715        }
716
717        let generic_name = ctx.generic_name();
718        let full_params = ctx.instance_formals(true); // adds x, ..., params
719        let method_attrs = &ctx.method.method_attrs;
720
721        // Emit the generic-doc marker BEFORE the source comment so that the
722        // write-time pass can place the standalone Rd page before the method block.
723        // This ensures the synthesised doc block (ending with NULL) is always
724        // separated from the method's roxygen comment by the source `# comment` line
725        // — preventing roxygen2 from merging the two blocks into one.
726        //
727        // Only package-owned (non-external, non-override) generics get a marker;
728        // fallback methods dispatch on S7::class_any and don't define a new generic.
729        if !ctx.has_generic_override() && !method_attrs.s7.fallback && !class_has_no_rd {
730            let dispatch_str = method_attrs
731                .s7
732                .dispatch
733                .as_deref()
734                .unwrap_or("x")
735                .replace(' ', "");
736            let no_dots_str = if method_attrs.s7.no_dots {
737                "true"
738            } else {
739                "false"
740            };
741            lines.push(format!(
742                ".__MX_GENERIC_DOC__(kind=\"S7\", generic=\"{generic_name}\", class=\"{class_name}\", export={should_export}, dispatch=\"{dispatch_str}\", no_dots={no_dots_str})"
743            ));
744        }
745
746        lines.push(ctx.source_comment(type_ident));
747
748        // For fallback methods (class_any), check class before using @ to extract
749        // the pointer. Non-S7 objects can't have @.ptr — error in R rather than
750        // passing a wrong type to Rust (which would segfault).
751        let self_expr = if method_attrs.s7.fallback {
752            "if (inherits(x, \"S7_object\")) x@.ptr else stop(paste0(\"expected an S7 object, got \", class(x)[[1]]))"
753        } else {
754            "x@.ptr"
755        };
756        let call = ctx.instance_call(self_expr);
757
758        // Determine dispatch class (fallback -> class_any, normal -> class_name)
759        let method_class = if method_attrs.s7.fallback {
760            "S7::class_any".to_string()
761        } else {
762            class_name.clone()
763        };
764
765        // Documentation - skip if class has @noRd.
766        // Use class-qualified @name to avoid duplicate \alias{generic} warnings
767        // when multiple S7 classes share the same generic (e.g., get_value on both
768        // S7TraitCounter and CounterTraitS7). The @export is replaced with
769        // @rawNamespace to explicitly export the bare generic name.
770        if !class_has_no_rd {
771            let qualified_name = format!("{}-{}", class_name, generic_name);
772            let method_doc =
773                MethodDocBuilder::new(&class_name, &generic_name, type_ident, &ctx.method.doc_tags)
774                    .with_suppress_params()
775                    .with_r_name(qualified_name);
776            let mut doc_lines = method_doc.build();
777            doc_lines.push(format!("#' @aliases {}${}", class_name, generic_name));
778            lines.extend(doc_lines);
779        }
780
781        if ctx.has_generic_override() {
782            // Parse "pkg::name" format for external generics
783            let (pkg, gen_name) = if generic_name.contains("::") {
784                let parts: Vec<&str> = generic_name.split("::").collect();
785                (parts[0].to_string(), parts[1].to_string())
786            } else {
787                ("base".to_string(), generic_name.clone())
788            };
789
790            // Use S7::new_external_generic for existing generics from other packages
791            lines.push(format!(
792                "if (!exists(\"{gen_name}\", mode = \"function\")) {{"
793            ));
794            lines.push(format!(
795                "  {gen_name} <- S7::new_external_generic(\"{pkg}\", \"{gen_name}\")"
796            ));
797            lines.push("}".to_string());
798
799            // Define method using the resolved generic name
800            let strategy = crate::ReturnStrategy::for_method(ctx.method);
801            let body_lines = crate::MethodReturnBuilder::new(call.clone())
802                .with_strategy(strategy)
803                .with_class_name(class_name.clone())
804                .build_s7_body();
805
806            let what = format!("{}.{}", generic_name, class_name);
807            lines.push(format!(
808                "S7::method({gen_name}, {method_class}) <- function({full_params}) {{"
809            ));
810            ctx.emit_method_prelude(&mut lines, "  ", &what);
811            lines.extend(body_lines);
812            lines.push("}".to_string());
813        } else {
814            // Create new S7 generic if it doesn't exist
815            // Use @rawNamespace to explicitly export the bare generic name.
816            // Plain @export would export the qualified @name (e.g., "ClassName-method")
817            // instead of the bare generic.
818            if should_export {
819                lines.push(format!("#' @rawNamespace export({})", generic_name));
820            }
821
822            // Determine dispatch arguments (default: "x", or custom via dispatch = "x,y")
823            let dispatch_args = if let Some(ref dispatch) = method_attrs.s7.dispatch {
824                // Multiple dispatch: "x,y" -> c("x", "y")
825                let args: Vec<&str> = dispatch.split(',').map(|s| s.trim()).collect();
826                if args.len() == 1 {
827                    format!("\"{}\"", args[0])
828                } else {
829                    format!(
830                        "c({})",
831                        args.iter()
832                            .map(|a| format!("\"{}\"", a))
833                            .collect::<Vec<_>>()
834                            .join(", ")
835                    )
836                }
837            } else {
838                "\"x\"".to_string()
839            };
840
841            // Determine function signature (with or without ...)
842            let generic_sig = if method_attrs.s7.no_dots {
843                // no_dots: strict generic without ...
844                if let Some(ref dispatch) = method_attrs.s7.dispatch {
845                    let args: Vec<&str> = dispatch.split(',').map(|s| s.trim()).collect();
846                    format!("function({}) S7::S7_dispatch()", args.join(", "))
847                } else {
848                    "function(x) S7::S7_dispatch()".to_string()
849                }
850            } else {
851                // Default: include ... for extra args
852                if let Some(ref dispatch) = method_attrs.s7.dispatch {
853                    let args: Vec<&str> = dispatch.split(',').map(|s| s.trim()).collect();
854                    format!("function({}, ...) S7::S7_dispatch()", args.join(", "))
855                } else {
856                    "function(x, ...) S7::S7_dispatch()".to_string()
857                }
858            };
859
860            lines.push(format!(
861                "if (!exists(\"{generic_name}\", mode = \"function\")) {{"
862            ));
863            lines.push(format!(
864                "  {generic_name} <- S7::new_generic(\"{generic_name}\", {dispatch_args}, {generic_sig})"
865            ));
866            lines.push("}".to_string());
867
868            // Define method
869            let strategy = crate::ReturnStrategy::for_method(ctx.method);
870            let body_lines = crate::MethodReturnBuilder::new(call)
871                .with_strategy(strategy)
872                .with_class_name(class_name.clone())
873                .build_s7_body();
874
875            // Use matching formals for method (with or without ...)
876            let method_formals = ctx.instance_formals_with_dots(true, !method_attrs.s7.no_dots);
877
878            let what = format!("{}.{}", generic_name, class_name);
879            lines.push(format!(
880                "S7::method({generic_name}, {method_class}) <- function({method_formals}) {{"
881            ));
882            ctx.emit_method_prelude(&mut lines, "  ", &what);
883            lines.extend(body_lines);
884            lines.push("}".to_string());
885        }
886        lines.push(String::new());
887
888        // Per-class fast-path dispatch shortcut (#949).
889        //
890        // Alongside the S7 generic, emit a plain non-generic function
891        // `<ClassName>_<method_name>(self, ...)` whose body is identical to the
892        // generic's method but calls `.Call` directly — bypassing
893        // `S7::S7_dispatch()` (class-walk + method-table lookup). On hot loops
894        // this is several times faster than the generic. The receiver is named
895        // `self` here (the generic names it `x`) and is wired through `self@.ptr`.
896        //
897        // Fallback methods dispatch on `S7::class_any`, so a per-class shortcut
898        // is meaningless — skip them. `s7(no_shortcut)` opts a method out
899        // explicitly (e.g. to avoid a name collision with a sidecar accessor).
900        if !method_attrs.s7.fallback && !method_attrs.s7.no_shortcut {
901            let method_name = ctx.method.r_method_name();
902            let shortcut_name = format!("{}_{}", class_name, method_name);
903            let shortcut_call = ctx.instance_call("self@.ptr");
904            let shortcut_formals =
905                ctx.instance_formals_with_receiver("self", !method_attrs.s7.no_dots);
906
907            // Document the shortcut as a standalone function merged onto the
908            // class @rdname page (same shape as static methods). Prepend a
909            // fast-path advisory describing the dispatch bypass and the
910            // subclass-override footgun, then reuse MethodDocBuilder for the
911            // @name / @rdname / @source / @param scaffolding so roxygen2 emits a
912            // complete \usage + \arguments block (no "undocumented argument"
913            // warning). `@param self` is documented explicitly because
914            // MethodDocBuilder skips the receiver.
915            if !class_has_no_rd {
916                lines.extend(shortcut_advisory_lines(&method_name, &class_name));
917                lines.push(format!("#' @param self A `{}` object.", class_name));
918                // `...` appears in the shortcut signature for parity with the S7
919                // generic but MethodDocBuilder doesn't document it, so emit it
920                // here to avoid an "Undocumented arguments" R CMD check warning.
921                if !method_attrs.s7.no_dots {
922                    lines.push(
923                        "#' @param ... Additional arguments; ignored by the fast-path shortcut."
924                            .to_string(),
925                    );
926                }
927                // Pass empty doc_tags: the method's own prose (@title/description)
928                // is already rendered on the shared @rdname page by the generic
929                // block above; re-emitting it here would duplicate it. We only
930                // need the @name / @rdname / @source / @param scaffolding so the
931                // shortcut's \usage is fully documented.
932                let mx_doc = ctx.match_arg_doc_placeholders();
933                let no_tags: [String; 0] = [];
934                let method_doc =
935                    MethodDocBuilder::new(&class_name, &method_name, type_ident, &no_tags)
936                        .with_r_params(&shortcut_formals)
937                        .with_match_arg_doc_placeholders(&mx_doc)
938                        .with_r_name(shortcut_name.clone());
939                lines.extend(method_doc.build());
940            }
941            // Export the shortcut when the class is exported, so users can reach it.
942            if should_export {
943                lines.push(format!("#' @export {}", shortcut_name));
944            }
945
946            let strategy = crate::ReturnStrategy::for_method(ctx.method);
947            let shortcut_body = crate::MethodReturnBuilder::new(shortcut_call)
948                .with_strategy(strategy)
949                .with_class_name(class_name.clone())
950                .with_chain_var("self".to_string())
951                .build_s7_body();
952
953            let what = format!("{}.{}", generic_name, class_name);
954            lines.push(format!(
955                "{shortcut_name} <- function({shortcut_formals}) {{"
956            ));
957            ctx.emit_method_prelude(&mut lines, "  ", &what);
958            lines.extend(shortcut_body);
959            lines.push("}".to_string());
960            lines.push(String::new());
961        }
962    }
963
964    // Static methods as regular functions
965    for ctx in parsed_impl.static_method_contexts() {
966        lines.push(ctx.source_comment(type_ident));
967        let method_name = ctx.method.r_method_name();
968        let fn_name = format!("{}_{}", class_name, method_name);
969
970        // Skip documentation if class has @noRd
971        if !class_has_no_rd {
972            let mx_doc = ctx.match_arg_doc_placeholders();
973            let method_doc =
974                MethodDocBuilder::new(&class_name, &method_name, type_ident, &ctx.method.doc_tags)
975                    .with_r_params(&ctx.params)
976                    .with_match_arg_doc_placeholders(&mx_doc)
977                    .with_r_name(fn_name.clone());
978            lines.extend(method_doc.build());
979        }
980        // Export static methods so users can call them (if class should be exported)
981        if should_export {
982            lines.push("#' @export".to_string());
983        }
984
985        lines.push(format!("{} <- function({}) {{", fn_name, ctx.params));
986
987        ctx.emit_method_prelude(&mut lines, "  ", &fn_name);
988
989        let strategy = crate::ReturnStrategy::for_method(ctx.method);
990        let return_expr = crate::MethodReturnBuilder::new(ctx.static_call())
991            .with_strategy(strategy)
992            .with_class_name(class_name.clone())
993            .build_s7_inline();
994        lines.push(format!("  {}", return_expr));
995
996        lines.push("}".to_string());
997        lines.push(String::new());
998    }
999
1000    // Phase 4: S7 convert() methods from Rust From/TryFrom patterns
1001    // Convert methods enable type coercion between S7 classes using S7::convert()
1002    //
1003    // Two patterns:
1004    // 1. convert_from = "OtherType" on static method: converts FROM OtherType TO this class
1005    //    Rust: fn from_other(other: OtherType) -> Self
1006    //    R: S7::method(S7::convert, list(OtherType, ThisClass)) <- function(from, to) ...
1007    //
1008    // 2. convert_to = "OtherType" on instance method: converts FROM this class TO OtherType
1009    //    Rust: fn to_other(&self) -> OtherType
1010    //    R: S7::method(S7::convert, list(ThisClass, OtherType)) <- function(from, to) ...
1011
1012    for method in &parsed_impl.methods {
1013        if !method.should_include() {
1014            continue;
1015        }
1016        let attrs = &method.method_attrs;
1017
1018        // Handle convert_from (static method pattern)
1019        // S7 convert signature is function(from, to) - one parameter for the source object
1020        if let Some(ref from_type) = attrs.s7.convert_from {
1021            let ctx = MethodContext::new(method, type_ident, parsed_impl.label());
1022
1023            // Documentation for convert method (skip if class has @noRd)
1024            if !class_has_no_rd {
1025                lines.push(format!("#' @name convert-{}-to-{}", from_type, class_name));
1026                lines.push(format!("#' @rdname {}", class_name));
1027                lines.push(crate::roxygen::method_source_tag(type_ident, &method.ident));
1028                // Add @aliases convert so roxygen2 emits \alias{convert} in the
1029                // merged .Rd file. Without this, R CMD check warns:
1030                //   "Objects in \usage without \alias in Rd file '...Rd': 'convert'"
1031                lines.push("#' @aliases convert".to_string());
1032                // S7's `convert` generic is `function(from, to, ...)`. Document
1033                // `...` so the rendered \usage{} matches and codoc passes.
1034                lines.push(
1035                    "#' @param ... Additional arguments passed to the S7 convert generic."
1036                        .to_string(),
1037                );
1038            }
1039
1040            // Generate: S7::method(S7::convert, list(FromType, ThisClass)) <- function(from, to, ...) ...
1041            // The convert_from method takes the source object as its sole parameter
1042            // We pass from@.ptr to extract the ExternalPtr from the S7 object
1043            let call_with_from = crate::r_wrapper_builder::DotCallBuilder::new(&ctx.c_ident)
1044                .with_self("from@.ptr")
1045                .build();
1046
1047            let strategy = crate::ReturnStrategy::for_method(method);
1048            let return_expr = crate::MethodReturnBuilder::new(call_with_from)
1049                .with_strategy(strategy)
1050                .with_class_name(class_name.clone())
1051                .build_s7_inline();
1052
1053            // Use imported `convert` - requires `@importFrom S7 convert` in package.
1054            // from_type is a cross-reference → placeholder so the resolver can look it up.
1055            // Method signature includes `...` to match the S7 `convert` generic
1056            // (function(from, to, ...)) and silence R CMD check codoc warnings.
1057            let from_type_ref = class_ref_or_verbatim(from_type);
1058            lines.push(format!(
1059                "S7::method(convert, list({}, {})) <- function(from, to, ...) {}",
1060                from_type_ref, class_name, return_expr
1061            ));
1062            lines.push(String::new());
1063        }
1064
1065        // Handle convert_to (instance method pattern)
1066        // S7 convert signature is function(from, to) - self becomes from
1067        if let Some(ref to_type) = attrs.s7.convert_to {
1068            let ctx = MethodContext::new(method, type_ident, parsed_impl.label());
1069
1070            // Documentation for convert method (skip if class has @noRd)
1071            if !class_has_no_rd {
1072                lines.push(format!("#' @name convert-{}-to-{}", class_name, to_type));
1073                lines.push(format!("#' @rdname {}", class_name));
1074                lines.push(crate::roxygen::method_source_tag(type_ident, &method.ident));
1075                // Add @aliases convert so roxygen2 emits \alias{convert} in the
1076                // merged .Rd file. Without this, R CMD check warns:
1077                //   "Objects in \usage without \alias in Rd file '...Rd': 'convert'"
1078                lines.push("#' @aliases convert".to_string());
1079                // S7's `convert` generic is `function(from, to, ...)`. Document
1080                // `...` so the rendered \usage{} matches and codoc passes.
1081                lines.push(
1082                    "#' @param ... Additional arguments passed to the S7 convert generic."
1083                        .to_string(),
1084                );
1085            }
1086
1087            // Generate: S7::method(convert, list(ThisClass, ToType)) <- function(from, to, ...) ...
1088            // The convert_to method is an instance method where self is mapped to from@.ptr
1089            let call = crate::r_wrapper_builder::DotCallBuilder::new(&ctx.c_ident)
1090                .with_self("from@.ptr")
1091                .build();
1092
1093            // to_type is a cross-reference → placeholder for resolver.
1094            // We also pass the placeholder to MethodReturnBuilder so the
1095            // emitted `ToType(.ptr = <result>)` uses the resolved name.
1096            let to_type_ref = class_ref_or_verbatim(to_type);
1097
1098            // Force ReturnSelf strategy for convert methods since they return S7 class types
1099            // that need to be wrapped: ToType(.ptr = <result>)
1100            let return_expr = crate::MethodReturnBuilder::new(call)
1101                .with_strategy(crate::ReturnStrategy::ReturnSelf)
1102                .with_class_name(to_type_ref.clone())
1103                .build_s7_inline();
1104
1105            // Use imported `convert` - requires `@importFrom S7 convert` in package.
1106            // Method signature includes `...` to match the S7 `convert` generic
1107            // (function(from, to, ...)) and silence R CMD check codoc warnings.
1108            lines.push(format!(
1109                "S7::method(convert, list({}, {})) <- function(from, to, ...) {}",
1110                class_name, to_type_ref, return_expr
1111            ));
1112            lines.push(String::new());
1113        }
1114    }
1115
1116    lines.join("\n")
1117}