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 (including
10//! multiple dispatch); use S4 when you need Bioconductor compatibility.
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        ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!(".__MX_CLASS_REF_OR_ANY_{0}__",
                name))
    })format!(".__MX_CLASS_REF_OR_ANY_{name}__")
66    } else {
67        name.to_string()
68    }
69}
70
71/// Name of the S7 fast-path shortcut `<ClassName>_<method>` (#949), or `None`
72/// when that name is not a syntactic R name.
73///
74/// A method whose R name is an operator (`r_name = "[["`, `"%op%"`) is reached
75/// through R's operator syntax (`obj[[i]]`), which dispatches through the
76/// generic. A shortcut named `` `Foo_[[` `` could only be called with backticks,
77/// so it is not emitted at all. Spell the method `s7(generic = "[[")` on a
78/// Rust method with an ordinary name to keep both the operator and a
79/// `Foo_<name>` shortcut. Shared by the inherent and trait S7 generators and
80/// the shortcut collision check, so all three agree on which names exist.
81pub(crate) fn s7_shortcut_name(class_name: &str, method_name: &str) -> Option<String> {
82    let name = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}_{1}", class_name, method_name))
    })format!("{class_name}_{method_name}");
83    crate::naming::is_syntactic_r_name(&name).then_some(name)
84}
85
86// region: S7 generic targets and dispatch arguments
87
88/// R's `Ops` group operators. S7 dispatches them on both operands through its
89/// own `(e1, e2, ...)` generics.
90pub(crate) const S7_OPS_OPERATORS: &[&str] = &[
91    "+", "-", "*", "/", "^", "%%", "%/%", "==", "!=", "<", "<=", ">=", ">", "&", "|",
92];
93
94/// Operators base R always provides and S7 dispatches on the receiver alone
95/// (through the base primitive as an S3 generic).
96const S7_BASE_S3_OPERATORS: &[&str] = &["[", "[[", "$"];
97
98/// The dispatch arguments S7 fixes for a two-operand operator generic:
99/// `(e1, e2)` for the `Ops` group, `(x, y)` for `%*%` (the `matrixOps` group).
100pub(crate) fn s7_operator_dispatch_args(op: &str) -> Option<[&'static str; 2]> {
101    if S7_OPS_OPERATORS.contains(&op) {
102        Some(["e1", "e2"])
103    } else if op == "%*%" {
104        Some(["x", "y"])
105    } else {
106        None
107    }
108}
109
110/// Where an S7 instance method's generic comes from.
111#[derive(#[automatically_derived]
impl ::core::fmt::Debug for S7GenericTarget {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        match self {
            Self::BaseOperator(__self_0) =>
                ::core::fmt::Formatter::debug_tuple_field1_finish(f,
                    "BaseOperator", &__self_0),
            Self::External { package: __self_0, name: __self_1 } =>
                ::core::fmt::Formatter::debug_struct_field2_finish(f,
                    "External", "package", __self_0, "name", &__self_1),
            Self::Local(__self_0) =>
                ::core::fmt::Formatter::debug_tuple_field1_finish(f, "Local",
                    &__self_0),
        }
    }
}Debug)]
112pub(crate) enum S7GenericTarget {
113    /// A base R operator (`[`, `[[`, `$`, the `Ops` group, `%*%`). It always
114    /// exists, so the method attaches to it directly: the package neither
115    /// defines, exports nor documents the generic.
116    BaseOperator(String),
117    /// `s7(generic = "pkg::name")`: another package's generic. It is bound as
118    /// `S7::new_external_generic()` under its bare name, which S7 resolves
119    /// whether or not `pkg` is loaded (registration waits for its `onLoad`).
120    External { package: String, name: String },
121    /// The package's own generic (`r_name`, the Rust name, or an unqualified
122    /// `s7(generic = ...)`). Reused when a usable generic already exists,
123    /// otherwise defined here; exported with a standalone doc page.
124    Local(String),
125}
126
127impl S7GenericTarget {
128    /// The generic's bare R name (no `pkg::` prefix).
129    pub(crate) fn name(&self) -> &str {
130        match self {
131            Self::BaseOperator(name) | Self::Local(name) => name,
132            Self::External { name, .. } => name,
133        }
134    }
135}
136
137/// The line that follows an `S7::method(<op>, ...) <-` registration on a base
138/// operator: `base::rm(list = "<op>")`.
139///
140/// `S7::method(`[[`, Class) <- f` is a replacement call, so R assigns its
141/// result, the base `[[` primitive, to `[[` in the package namespace. A
142/// generic the namespace binds counts as the package's own when R registers
143/// the package's S3 methods (`registerS3methods()`), so every `S3method("[[",
144/// ...)` of the package would land in the namespace's own methods table
145/// instead of base's: those methods would dispatch only from code inside the
146/// namespace, never from a user's `x[[i]]` or from `lapply()`. Removing the
147/// binding right after the registration keeps the S7 call itself unchanged.
148pub(crate) fn s7_base_operator_unbind(op: &str) -> String {
149    ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("base::rm(list = \"{0}\")", op))
    })format!("base::rm(list = \"{op}\")")
150}
151
152/// Classify a method's generic name (`ctx.generic_name()`).
153pub(crate) fn s7_generic_target(generic_name: &str) -> S7GenericTarget {
154    let (package, name) = match generic_name.split_once("::") {
155        Some((package, name)) => (Some(package), name),
156        None => (None, generic_name),
157    };
158    let base_operator =
159        S7_BASE_S3_OPERATORS.contains(&name) || s7_operator_dispatch_args(name).is_some();
160    match package {
161        None | Some("base") if base_operator => S7GenericTarget::BaseOperator(name.to_string()),
162        Some(package) => S7GenericTarget::External {
163            package: package.to_string(),
164            name: name.to_string(),
165        },
166        None => S7GenericTarget::Local(name.to_string()),
167    }
168}
169
170/// The S7 dispatch arguments of an instance method: `s7(dispatch = "a, b")`,
171/// else the pair an operator generic fixes (`e1, e2`), else `x`.
172///
173/// The first argument names the receiver; the others are the method's
174/// leading parameters (validated by `check_s7_dispatch`).
175pub(crate) fn s7_dispatch_args(method: &ParsedMethod, target: &S7GenericTarget) -> Vec<String> {
176    if let Some(dispatch) = method.method_attrs.s7.dispatch.as_deref() {
177        return dispatch.split(',').map(|a| a.trim().to_string()).collect();
178    }
179    match s7_operator_dispatch_args(target.name()) {
180        Some(pair) => pair.iter().map(|a| a.to_string()).collect(),
181        None => ::alloc::boxed::box_assume_init_into_vec_unsafe(::alloc::intrinsics::write_box_via_move(::alloc::boxed::Box::new_uninit(),
        ["x".to_string()]))vec!["x".to_string()],
182    }
183}
184
185/// The `S7::method()` signature: the class (or `S7::class_any` for a
186/// fallback) for the receiver, and `S7::class_any` for every other dispatch
187/// argument.
188///
189/// The other arguments are not narrowed. The method's own argument checks and
190/// Rust conversions are the type check, and they report the parameter by name.
191/// No single S7 class matches what a parameter accepts in general (an
192/// `ExternalPtr<T>` takes the class object, a bare external pointer or a list
193/// with `.ptr`; `coerce` widens numeric parameters), so a narrower class would
194/// turn accepted values into "Can't find method" errors. A more specific
195/// method registered in R still wins over this one.
196fn s7_method_signature(class_name: &str, n_dispatch: usize, fallback: bool) -> String {
197    let receiver = if fallback {
198        "S7::class_any"
199    } else {
200        class_name
201    };
202    if n_dispatch <= 1 {
203        receiver.to_string()
204    } else {
205        let mut parts = ::alloc::boxed::box_assume_init_into_vec_unsafe(::alloc::intrinsics::write_box_via_move(::alloc::boxed::Box::new_uninit(),
        [receiver]))vec![receiver];
206        parts.extend(std::iter::repeat_n("S7::class_any", n_dispatch - 1));
207        ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("list({0})", parts.join(", ")))
    })format!("list({})", parts.join(", "))
208    }
209}
210
211/// A character vector literal: `"x"` or `c("x", "y")`.
212fn r_chr_vector(values: &[String]) -> String {
213    let quoted: Vec<String> = values.iter().map(|v| ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("\"{0}\"", v))
    })format!("\"{v}\"")).collect();
214    match quoted.as_slice() {
215        [single] => single.clone(),
216        _ => ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("c({0})", quoted.join(", ")))
    })format!("c({})", quoted.join(", ")),
217    }
218}
219
220/// Emit the load-time definition of a package-local S7 generic: define it
221/// when no binding exists, shadow a plain (non-generic) function, otherwise
222/// reuse the existing generic.
223///
224/// The generic's formals are the dispatch arguments (plus `...` unless
225/// `no_dots`); the shadow's `class_any` fallback takes the same formals and
226/// forwards them, so every method S7 registers on it is signature-compatible.
227fn emit_s7_local_generic(
228    lines: &mut Vec<String>,
229    generic_name: &str,
230    dispatch_args: &[String],
231    include_dots: bool,
232) {
233    let generic_symbol = crate::naming::r_def_name(generic_name);
234    let dispatch_vec = r_chr_vector(dispatch_args);
235    let mut generic_formals = dispatch_args.join(", ");
236    if include_dots {
237        generic_formals.push_str(", ...");
238    }
239    let generic_fun = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("function({0}) S7::S7_dispatch()",
                generic_formals))
    })format!("function({generic_formals}) S7::S7_dispatch()");
240    let class_any_spec = s7_method_signature("S7::class_any", dispatch_args.len(), true);
241
242    // Classify any existing binding of `generic_name` (#1114). A bare
243    // `exists(...)` check is wrong: if the name resolves to a *plain*
244    // base/stats closure (var, get, row, col, diag, reshape, ...),
245    // `S7::method(<closure>, ...) <-` errors at load ("generic is a
246    // function, but not an S3 generic function") and the package fails
247    // to install. Only create/reuse when the existing binding is
248    // something S7::method<- accepts as a generic: an S7 generic, a
249    // primitive, an S3 standard generic, or an S4 generic. Otherwise
250    // shadow it with a package-local S7 generic.
251    //
252    // Emit an `if (!base::exists(...))` / `else if ({classifier})` chain
253    // rather than a top-level `.mx_gen <- ...` assignment. The leading
254    // statement must NOT be an assignment: roxygen2 documents the first
255    // top-level binding after the doc block, and a `.mx_gen <- ...` there
256    // pollutes every S7 man page with a bogus \alias{.mx_gen}/\usage. The
257    // `.mx_gen` binding used by the classifier lives inside the braced
258    // `else if` condition, which roxygen2 does not descend into.
259    //
260    // `base::exists`/`base::get`: once we define a shadow generic named
261    // e.g. `get`, a bare `get(...)` in a later generic's classifier would
262    // route through our own generic. Qualify to stay robust.
263    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("if (!base::exists(\"{0}\", mode = \"function\")) {{",
                generic_name))
    })format!(
264        "if (!base::exists(\"{generic_name}\", mode = \"function\")) {{"
265    ));
266    lines.push("  # No existing binding; define a plain package-local S7 generic.".to_string());
267    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  {0} <- S7::new_generic(\"{1}\", {2}, {3})",
                generic_symbol, generic_name, dispatch_vec, generic_fun))
    })format!(
268        "  {generic_symbol} <- S7::new_generic(\"{generic_name}\", {dispatch_vec}, {generic_fun})"
269    ));
270    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("}} else if (local({{ .mx_gen <- base::get(\"{0}\", mode = \"function\"); !(inherits(.mx_gen, \"S7_generic\") || is.primitive(.mx_gen) || isTRUE(utils::isS3stdGeneric(.mx_gen)) || methods::isGeneric(\"{0}\")) }})) {{",
                generic_name))
    })format!(
271        "}} else if (local({{ .mx_gen <- base::get(\"{generic_name}\", mode = \"function\"); !(inherits(.mx_gen, \"S7_generic\") || is.primitive(.mx_gen) || isTRUE(utils::isS3stdGeneric(.mx_gen)) || methods::isGeneric(\"{generic_name}\")) }})) {{"
272    ));
273    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  # `{0}` resolves to a plain (non-generic) function S7 cannot",
                generic_name))
    })format!(
274        "  # `{generic_name}` resolves to a plain (non-generic) function S7 cannot"
275    ));
276    lines.push(
277        "  # attach a method to. Define a package-local S7 generic that shadows it,".to_string(),
278    );
279    lines.push(
280        "  # with a class_any fallback delegating to the masked function so ordinary".to_string(),
281    );
282    lines.push(
283        "  # (non-S7) calls (e.g. var(1:10)) keep working. S4 generics also take this".to_string(),
284    );
285    lines.push("  # path; the fallback preserves their dispatch.".to_string());
286    // `local()` gives the fallback closure its own environment holding an
287    // eagerly-assigned `.mx_masked` (assignment forces the value now: a
288    // function *argument* would stay an unforced promise and later see the
289    // reused `.mx_gen`, which is why we don't pass it as one).
290    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  {0} <- local({{",
                generic_symbol))
    })format!("  {generic_symbol} <- local({{"));
291    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    .mx_masked <- base::get(\"{0}\", mode = \"function\")",
                generic_name))
    })format!(
292        "    .mx_masked <- base::get(\"{generic_name}\", mode = \"function\")"
293    ));
294    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    .mx_g <- S7::new_generic(\"{0}\", {1}, {2})",
                generic_name, dispatch_vec, generic_fun))
    })format!(
295        "    .mx_g <- S7::new_generic(\"{generic_name}\", {dispatch_vec}, {generic_fun})"
296    ));
297    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    S7::method(.mx_g, {0}) <- function({1}) .mx_masked({1})",
                class_any_spec, generic_formals))
    })format!(
298        "    S7::method(.mx_g, {class_any_spec}) <- function({generic_formals}) .mx_masked({generic_formals})"
299    ));
300    lines.push("    .mx_g".to_string());
301    lines.push("  })".to_string());
302    lines.push("}".to_string());
303    lines.push("# else: existing usable generic (S7/primitive/S3/S4) — reuse as-is.".to_string());
304}
305
306// endregion
307
308/// Roxygen prose for the fast-path dispatch shortcut advisory block.
309///
310/// Shared between the inherent-impl S7 generator ([`generate_s7_class`])
311/// and the trait-impl S7 generator
312/// (`miniextendr_impl_trait::r_wrappers::generate_trait_s7_r_wrapper`) so both
313/// emit identical guidance. Returns `#'`-prefixed lines (no trailing `@param`
314/// / `@name` scaffolding — the caller appends those).
315pub(crate) fn shortcut_advisory_lines(method_name: &str, class_name: &str) -> Vec<String> {
316    ::alloc::boxed::box_assume_init_into_vec_unsafe(::alloc::intrinsics::write_box_via_move(::alloc::boxed::Box::new_uninit(),
        [::alloc::__export::must_use({
                        ::alloc::fmt::format(format_args!("#\' Fast-path shortcut for the `{0}` S7 method on `{1}`.",
                                method_name, class_name))
                    }), "#'".to_string(),
                "#' Calls the underlying Rust routine directly, bypassing `S7::S7_dispatch()`".to_string(),
                "#' (the class walk + method-table lookup). Use in hot loops where the".to_string(),
                "#' per-call dispatch overhead matters. **Footgun:** this shortcut does not".to_string(),
                "#' perform subclass dispatch: a method override defined on a child class".to_string(),
                "#' will *not* be honoured. Use the generic when subclassing is possible.".to_string()]))vec![
317        format!(
318            "#' Fast-path shortcut for the `{}` S7 method on `{}`.",
319            method_name, class_name
320        ),
321        "#'".to_string(),
322        "#' Calls the underlying Rust routine directly, bypassing `S7::S7_dispatch()`".to_string(),
323        "#' (the class walk + method-table lookup). Use in hot loops where the".to_string(),
324        "#' per-call dispatch overhead matters. **Footgun:** this shortcut does not".to_string(),
325        // ASCII only: a standalone `tools::checkRd()` flags non-ASCII Rd text.
326        "#' perform subclass dispatch: a method override defined on a child class".to_string(),
327        "#' will *not* be honoured. Use the generic when subclassing is possible.".to_string(),
328    ]
329}
330
331/// Map a Rust return type to an S7 class name.
332///
333/// Returns `None` if the type doesn't map to any S7 class — the caller
334/// then omits the `class = …` constraint (so S7 uses `class_any`).
335///
336/// # S7 Class Mapping
337///
338/// | Rust Type | S7 Class |
339/// |-----------|----------|
340/// | `i32`, `i16`, `i8` | `class_integer` |
341/// | `f64`, `f32` | `class_double` |
342/// | `bool` | `class_logical` |
343/// | `u8` | `class_raw` |
344/// | `String`, `&str` | `class_character` |
345/// | `Vec<i32>` | `class_integer` |
346/// | `Vec<f64>` | `class_double` |
347/// | `Vec<bool>` | `class_logical` |
348/// | `Vec<String>` | `class_character` |
349/// | `Option<T>` | `NULL \| class_T` (union) |
350/// | `SomeUserType` (bare ident) | `.__MX_CLASS_REF_SomeUserType__` |
351///
352/// For bare user-defined path types the macro emits the same
353/// `.__MX_CLASS_REF_<Type>__` placeholder used for parent-class and
354/// `convert_from`/`convert_to` references (see [`#154`]). The resolver in
355/// `miniextendr_api::registry::write_r_wrappers_to_file` swaps the
356/// placeholder for the R-visible class name recorded in `MX_CLASS_NAMES`
357/// — so `class = "Override"` on an S7 impl block is honored, and child
358/// class properties tighten from `class_any` to the real class.
359///
360/// Unresolved types fall through the existing CLASS_REF mechanism:
361/// the bare Rust name is emitted with a compile-time warning. If the
362/// user returns a type that isn't a registered class at all, they'll
363/// see that warning + a `object '…' not found` at R load time.
364pub(super) fn rust_type_to_s7_class(ty: &syn::Type) -> Option<String> {
365    match ty {
366        syn::Type::Path(type_path) => {
367            let seg = type_path.path.segments.last()?;
368            let ident = seg.ident.to_string();
369
370            match ident.as_str() {
371                // Scalar types
372                "i32" | "i16" | "i8" | "isize" => Some("S7::class_integer".to_string()),
373                "f64" | "f32" => Some("S7::class_double".to_string()),
374                "bool" => Some("S7::class_logical".to_string()),
375                "u8" => Some("S7::class_raw".to_string()),
376                "String" => Some("S7::class_character".to_string()),
377
378                // Vec types - check inner type
379                "Vec" => {
380                    if let syn::PathArguments::AngleBracketed(args) = &seg.arguments
381                        && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
382                    {
383                        // Recursively get the inner type's class
384                        return rust_type_to_s7_class(inner);
385                    }
386                    None
387                }
388
389                // Option types - create union with NULL
390                "Option" => {
391                    if let syn::PathArguments::AngleBracketed(args) = &seg.arguments
392                        && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
393                        && let Some(inner_class) = rust_type_to_s7_class(inner)
394                    {
395                        return Some(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("NULL | {0}", inner_class))
    })format!("NULL | {}", inner_class));
396                    }
397                    None
398                }
399
400                // Result types - use the Ok type
401                "Result" => {
402                    if let syn::PathArguments::AngleBracketed(args) = &seg.arguments
403                        && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
404                    {
405                        return rust_type_to_s7_class(inner);
406                    }
407                    None
408                }
409
410                // Bare, un-generic single-segment identifier — reuse the
411                // CLASS_REF write-time placeholder so the resolver swaps
412                // in the registered R-visible class name (honoring any
413                // `class = "Override"` on the referenced impl block).
414                // Paths with `::`, generics, or a lowercase leading char
415                // are rejected to avoid matching crate-local aliases,
416                // primitives, or type parameters — those fall through
417                // to `None` and the caller omits the `class =` entirely.
418                _ if type_path.path.segments.len() == 1
419                    && #[allow(non_exhaustive_omitted_patterns)] match seg.arguments {
    syn::PathArguments::None => true,
    _ => false,
}matches!(seg.arguments, syn::PathArguments::None)
420                    && is_bare_identifier(&ident)
421                    && ident.chars().next().is_some_and(|c| c.is_ascii_uppercase()) =>
422                {
423                    // S7 property class constraint: use the OR_ANY variant so
424                    // an unregistered type (or a registered-but-non-S7 class)
425                    // falls back silently to `class_any`, restoring pre-#154
426                    // behavior for those edge cases (#203).
427                    Some(class_ref_or_any_or_verbatim(&ident))
428                }
429
430                _ => None,
431            }
432        }
433        syn::Type::Reference(type_ref) => {
434            // Handle &str
435            if let syn::Type::Path(type_path) = type_ref.elem.as_ref()
436                && let Some(seg) = type_path.path.segments.last()
437                && seg.ident == "str"
438            {
439                return Some("S7::class_character".to_string());
440            }
441            // Recurse for other reference types
442            rust_type_to_s7_class(&type_ref.elem)
443        }
444        _ => None,
445    }
446}
447
448/// Generates the R wrapper of an S7-style class, in the parts the wrapper
449/// writer merges per type ([`super::ClassWrapper`], #1776).
450///
451/// Produces the following R code:
452/// - Class definition: `ClassName <- S7::new_class("ClassName", ...)` with a `.ptr` property
453///   of `class_any` holding the `ExternalPtr`, plus optional computed properties
454/// - Constructor: inline in `new_class(constructor = function(...) ...)`, supports
455///   `.ptr` shortcut parameter for wrapping pre-built ExternalPtr returns
456/// - Properties: `S7::new_property(...)` for each getter/setter/validator annotated
457///   with `#[miniextendr(s7(getter))]` etc., with support for class constraints,
458///   defaults, required, frozen, and deprecated modifiers
459/// - Instance methods: `S7::new_generic(...)` + `S7::method(generic, class)` pairs
460///   dispatching to Rust `.Call()` wrappers via `x@.ptr` (see
461///   [`S7GenericTarget`] for where the generic comes from)
462/// - External generics: `S7::new_external_generic("pkg", "name", dispatch_args)`
463///   for `s7(generic = "pkg::name")`
464/// - Multiple dispatch: via `#[miniextendr(s7(dispatch = "x, other"))]`, and on
465///   `(e1, e2)` for the `Ops` operators
466/// - Fallback methods: `S7::method(generic, S7::class_any)` with `tryCatch` for
467///   safe slot access on non-S7 objects
468/// - Static methods: regular functions named `ClassName_method(...)`
469/// - Convert methods: `S7::method(convert, list(From, To))` for `convert_from`
470///   and `convert_to` annotations
471/// - S7 parent/abstract: optional `parent` and `abstract = TRUE` in class definition
472///
473/// Roxygen2 documentation and `@importFrom S7 ...` tags are generated automatically.
474///
475/// The class definition, with its properties and constructor, is the head; the
476/// methods, static methods and `convert()` methods are the members.
477pub fn generate_s7_class(parsed_impl: &ParsedImpl) -> super::ClassWrapper {
478    use crate::r_class_formatter::{
479        ClassDocBuilder, MethodContext, MethodDocBuilder, ParsedImplExt, should_export_from_tags,
480    };
481
482    let class_name = parsed_impl.class_name();
483    let type_ident = &parsed_impl.type_ident;
484    let class_doc_tags = &parsed_impl.doc_tags;
485    // Check if class has @noRd - if so, skip method documentation and exports. A
486    // plain `noexport` (without `internal`) is folded in too — it must suppress
487    // Rd contribution entirely, matching `ClassDocBuilder::build`'s `suppress_rd`
488    // gate. `should_export` (below) already independently gates @export/@exportMethod.
489    let class_has_no_rd = crate::roxygen::has_roxygen_tag(class_doc_tags, "noRd")
490        || (parsed_impl.noexport && !parsed_impl.internal);
491    let should_export =
492        should_export_from_tags(class_doc_tags, parsed_impl.noexport || parsed_impl.internal);
493
494    let mut w = super::ClassWrapper::default();
495    let mut lines = Vec::new();
496
497    // Collect S7 property getters, setters, and validators
498    // Property name is: s7_prop if specified, else method name
499    // We store method idents so we can look them up later
500    /// Accumulated metadata for a single S7 property, built up from getter,
501    /// setter, and validator method annotations during the first pass over methods.
502    struct S7Property {
503        /// Property name (from `#[miniextendr(s7(prop = "..."))]` or the method ident).
504        name: String,
505        /// Ident of the method annotated with `#[miniextendr(s7(getter))]`.
506        getter_method_ident: Option<String>,
507        /// Ident of the method annotated with `#[miniextendr(s7(setter))]`.
508        setter_method_ident: Option<String>,
509        /// Ident of the method annotated with `#[miniextendr(s7(validate))]`.
510        validator_method_ident: Option<String>,
511        /// S7 class type inferred from the getter's return type (e.g., `"S7::class_double"`).
512        class_type: Option<String>,
513        /// Default value as an R expression string (from `#[miniextendr(s7(default = "..."))]`).
514        default_value: Option<String>,
515        /// When `true`, the property errors if not provided during construction.
516        required: bool,
517        /// When `true`, the property can only be set once (subsequent sets error).
518        frozen: bool,
519        /// If set, a deprecation warning is emitted when the property is accessed or set.
520        deprecated: Option<String>,
521        /// Documentation extracted from the getter method's first doc line.
522        /// Used to emit `#' @prop name doc` in the class-level roxygen block.
523        doc: Option<String>,
524    }
525
526    let mut properties: std::collections::BTreeMap<String, S7Property> =
527        std::collections::BTreeMap::new();
528    let mut property_method_idents: std::collections::HashSet<String> =
529        std::collections::HashSet::new();
530
531    // First pass: collect all property methods (getters, setters, validators)
532    for method in &parsed_impl.methods {
533        if !method.should_include() {
534            continue;
535        }
536        let attrs = &method.method_attrs;
537
538        if attrs.s7.getter || attrs.s7.setter || attrs.s7.validate {
539            let method_ident = crate::naming::ident_name(&method.ident);
540            let prop_name = attrs
541                .s7
542                .prop
543                .clone()
544                .unwrap_or_else(|| method_ident.clone());
545
546            property_method_idents.insert(method_ident.clone());
547
548            let entry = properties.entry(prop_name.clone()).or_insert(S7Property {
549                name: prop_name,
550                getter_method_ident: None,
551                setter_method_ident: None,
552                validator_method_ident: None,
553                class_type: None,
554                default_value: None,
555                required: false,
556                frozen: false,
557                deprecated: None,
558                doc: None,
559            });
560
561            if attrs.s7.getter {
562                entry.getter_method_ident = Some(method_ident.clone());
563                // Extract S7 class type from getter's return type
564                if let syn::ReturnType::Type(_, ret_type) = &method.sig.output {
565                    entry.class_type = rust_type_to_s7_class(ret_type);
566                }
567                // Capture property attributes from getter
568                if let Some(ref default) = attrs.s7.default {
569                    entry.default_value = Some(default.clone());
570                }
571                if attrs.s7.required {
572                    entry.required = true;
573                }
574                if attrs.s7.frozen {
575                    entry.frozen = true;
576                }
577                if let Some(ref msg) = attrs.s7.deprecated {
578                    entry.deprecated = Some(msg.clone());
579                }
580                // Extract full doc from getter's doc comment for @prop documentation
581                // in the class-level roxygen block.  After #578 the auto_description
582                // path emits @title (para 1), @description (para 2), @details (para 3+).
583                // We collect all three to surface the complete doc under @prop.
584                // Multi-line @prop is supported by roxygen2 8.0.0 (continuation lines
585                // indented with two spaces).
586                entry.doc = extract_prop_doc_from_tags(&method.doc_tags);
587            }
588            if attrs.s7.setter {
589                entry.setter_method_ident = Some(method_ident.clone());
590            }
591            if attrs.s7.validate {
592                entry.validator_method_ident = Some(method_ident);
593            }
594        }
595    }
596
597    // Helper to find method by ident
598    let find_method = |ident: &str| -> Option<&ParsedMethod> {
599        parsed_impl.methods.iter().find(|m| m.ident == ident)
600    };
601
602    // Determine imports based on whether we have properties and what class types are used
603    let base_imports = "new_class class_any new_object S7_object new_generic method";
604    let mut import_parts: Vec<&str> = ::alloc::boxed::box_assume_init_into_vec_unsafe(::alloc::intrinsics::write_box_via_move(::alloc::boxed::Box::new_uninit(),
        [base_imports]))vec![base_imports];
605
606    if !properties.is_empty() {
607        import_parts.push("new_property");
608    }
609
610    // The `convert()` methods bind S7's `convert` by name: the class docs
611    // import it for whichever impl block of the type has them.
612    let has_convert_methods = parsed_impl.methods.iter().any(|m| {
613        m.should_include()
614            && (m.method_attrs.s7.convert_from.is_some() || m.method_attrs.s7.convert_to.is_some())
615    });
616    if has_convert_methods {
617        w.class_doc_lines
618            .push("#' @importFrom S7 convert".to_string());
619    }
620
621    // Collect unique S7 class types used in properties
622    let mut class_imports: std::collections::HashSet<&str> = std::collections::HashSet::new();
623    for prop in properties.values() {
624        if let Some(ref class_type) = prop.class_type {
625            // Extract class name from "S7::class_xxx" or "NULL | S7::class_xxx"
626            for part in class_type.split('|') {
627                let part = part.trim();
628                if let Some(class_name) = part.strip_prefix("S7::") {
629                    class_imports.insert(class_name);
630                }
631            }
632        }
633    }
634    // Sort for deterministic output
635    let mut sorted_imports: Vec<&str> = class_imports.into_iter().collect();
636    sorted_imports.sort();
637    for class_name in sorted_imports {
638        import_parts.push(class_name);
639    }
640
641    let imports = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("@importFrom S7 {0}",
                import_parts.join(" ")))
    })format!("@importFrom S7 {}", import_parts.join(" "));
642
643    // Class definition with documentation
644    let class_docs = ClassDocBuilder::new(&class_name, type_ident, class_doc_tags, "S7")
645        .with_imports(&imports)
646        .with_export_control(parsed_impl.internal, parsed_impl.noexport);
647    lines.extend(class_docs.build());
648    w.class_docs = class_docs.docs();
649    w.mark_class_doc_lines(&mut lines, parsed_impl);
650
651    // Document constructor params — include constructor @param tags and auto-generate
652    // for undocumented ones. Class-level @param tags are already emitted by ClassDocBuilder;
653    // constructor-level @param tags must be explicitly pushed here since S7 inlines the
654    // constructor inside new_class().
655    // Skip if class has @noRd
656    if !class_has_no_rd {
657        if let Some(ctx) = parsed_impl.constructor_context() {
658            let mx_doc = ctx.choice_param_docs();
659            for param in ctx.params.split(", ").filter(|p| !p.is_empty()) {
660                let param_name = param.split('=').next().unwrap_or(param).trim();
661                if param_name == ".ptr" || param_name == "..." {
662                    continue;
663                }
664                // Check if already documented at the class level (impl block doc_tags)
665                let in_class_docs = crate::roxygen::param_documented(class_doc_tags, param_name);
666                if in_class_docs {
667                    continue; // Already emitted by ClassDocBuilder
668                }
669                // Check if documented in the constructor method's doc_tags
670                let ctor_tag = crate::roxygen::find_param_tag(&ctx.method.doc_tags, param_name);
671                if let Some(tag) = ctor_tag {
672                    // A wrapped `@param` keeps every line inside the block.
673                    crate::roxygen::push_roxygen_tags_str(&mut lines, &[tag.as_str()]);
674                } else if crate::roxygen::params_documented_elsewhere(
675                    class_doc_tags,
676                    Some(&class_name),
677                ) {
678                    // The class block joins (`@rdname`) or inherits from a
679                    // topic that documents the argument (#1590).
680                    continue;
681                } else if let Some(doc) = mx_doc.get(param_name) {
682                    // Choice param: the literal `choices(...)` line, or a
683                    // match_arg placeholder rewritten at wrapper write time to
684                    // the rendered choice description (#210).
685                    lines.push(super::class_default(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @param {0} {1}", param_name,
                doc))
    })format!(
686                        "#' @param {} {}",
687                        param_name, doc
688                    )));
689                } else {
690                    lines.push(super::class_default(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @param {0} (undocumented)",
                param_name))
    })format!(
691                        "#' @param {} (undocumented)",
692                        param_name
693                    )));
694                }
695            }
696        }
697        // .ptr is always a constructor param. On an author topic (impl-level
698        // `@rdname other`) the topic's own block documents it: the class block
699        // sorts after that block, and roxygen2 keeps the later `@param` (#1590).
700        if !crate::roxygen::has_roxygen_tag(class_doc_tags, "param .ptr")
701            && !crate::roxygen::joins_author_topic(class_doc_tags, Some(&class_name))
702        {
703            lines.push(super::class_default(
704                "#' @param .ptr Internal pointer (used by static methods, not for direct use).",
705            ));
706        }
707
708        // @prop tags for impl-block S7 properties (getter/setter pairs).
709        // These appear in the class-level @rdname page via roxygen2's @prop tag.
710        //
711        // Properties that ARE constructor formals are already documented above via
712        // @param and must NOT also receive @prop — roxygen2 8.0.0 treats @param /
713        // @prop as disjoint sets (constructor formals → @param; getter/setter-only
714        // props → @prop). Collect the constructor-formal names and skip them here.
715        let constructor_param_names: std::collections::HashSet<String> =
716            if let Some(ctx) = parsed_impl.constructor_context() {
717                ctx.params
718                    .split(", ")
719                    .filter(|p| !p.is_empty())
720                    .map(|p| p.split('=').next().unwrap_or(p).trim().to_string())
721                    .filter(|n| n != ".ptr" && n != "...")
722                    .collect()
723            } else {
724                std::collections::HashSet::new()
725            };
726        for prop in properties.values() {
727            if constructor_param_names.contains(&prop.name) {
728                continue; // already documented as @param above
729            }
730            let doc = prop.doc.as_deref().unwrap_or("(undocumented property)");
731            // Emit @prop with multi-line continuation support (roxygen2 8.0.0+).
732            // First line: `#' @prop <name> <para1>`.
733            // Additional paragraphs: `#'   <continuation>` (two-space indent),
734            // a bare `#'` between them.
735            let mut prop_lines = doc.lines();
736            if let Some(first_line) = prop_lines.next() {
737                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @prop {0} {1}", prop.name,
                first_line))
    })format!("#' @prop {} {}", prop.name, first_line));
738                for continuation in prop_lines {
739                    lines.push(if continuation.trim().is_empty() {
740                        "#'".to_string()
741                    } else {
742                        ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\'   {0}", continuation))
    })format!("#'   {}", continuation)
743                    });
744                }
745            } else {
746                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @prop {0} {1}", prop.name,
                doc))
    })format!("#' @prop {} {}", prop.name, doc));
747            }
748        }
749
750        // @prop tags for sidecar (r_data_accessors) properties.
751        // Emitted via a write-time placeholder that MX_S7_SIDECAR_PROPS resolves.
752        // The placeholder is replaced in `write_r_wrappers_to_file` with one
753        // `#' @prop field doc` line per registered sidecar field.
754        if parsed_impl.r_data_accessors.enabled() {
755            let type_name = type_ident.to_string();
756            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!(".__MX_S7_SIDECAR_PROP_DOCS_{0}__",
                type_name))
    })format!(".__MX_S7_SIDECAR_PROP_DOCS_{type_name}__"));
757        }
758    }
759
760    // S7::new_class — optionally include parent and abstract
761    if let Some(ref parent) = parsed_impl.s7_parent {
762        // Use a placeholder so the resolver can look up the actual R class name
763        // at wrapper write time (handles `class = "Override"` on the parent).
764        let parent_ref = class_ref_or_verbatim(parent);
765        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} <- S7::new_class(\"{1}\", parent = {2},",
                class_name, class_name, parent_ref))
    })format!(
766            "{} <- S7::new_class(\"{}\", parent = {},",
767            class_name, class_name, parent_ref
768        ));
769    } else {
770        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} <- S7::new_class(\"{1}\",",
                class_name, class_name))
    })format!(
771            "{} <- S7::new_class(\"{}\",",
772            class_name, class_name
773        ));
774    }
775
776    if parsed_impl.s7_abstract {
777        lines.push("  abstract = TRUE,".to_string());
778    }
779
780    // Properties - .ptr holds the ExternalPtr, plus computed/dynamic properties
781    // When r_data_accessors is set, merge with sidecar properties from #[derive(ExternalPtr)]
782    // Collect property items into a vec, then join with ",\n" to avoid bare commas on standalone lines
783    let mut prop_items: Vec<String> = Vec::new();
784    prop_items.push("    .ptr = S7::class_any".to_string());
785
786    // Generate computed/dynamic properties
787    for prop in properties.values() {
788        // Generate property definition
789        let mut prop_parts = Vec::new();
790
791        // Add class constraint if known (inferred from getter return type)
792        if let Some(ref class_type) = prop.class_type {
793            prop_parts.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("class = {0}", class_type))
    })format!("class = {}", class_type));
794        }
795
796        // Handle default value or required pattern
797        if prop.required {
798            // Required pattern: error if not provided
799            prop_parts.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("default = quote(stop(\"@{0} is required\"))",
                prop.name))
    })format!(
800                "default = quote(stop(\"@{} is required\"))",
801                prop.name
802            ));
803        } else if let Some(ref default) = prop.default_value {
804            // Explicit default value (R expression)
805            prop_parts.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("default = {0}", default))
    })format!("default = {}", default));
806        }
807
808        // Add validator if present
809        if let Some(ref validator_ident) = prop.validator_method_ident
810            && let Some(validator_method) = find_method(validator_ident)
811        {
812            let ctx = MethodContext::new(validator_method, type_ident);
813            // Validator is called with just the value, not self.
814            // Use null_call_attribution: this runs inside S7's dispatch lambda, so
815            // sys.call() would name S7 internals, not the user's call site.
816            let validator_call = crate::r_wrapper_builder::DotCallBuilder::new(&ctx.c_ident)
817                .null_call_attribution()
818                .with_args(&["value"])
819                .build();
820            prop_parts.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("validator = function(value) {0}",
                validator_call))
    })format!("validator = function(value) {validator_call}"));
821        }
822
823        // Generate getter (with optional deprecation warning)
824        if let Some(ref getter_ident) = prop.getter_method_ident
825            && let Some(getter_method) = find_method(getter_ident)
826        {
827            let ctx = MethodContext::new(getter_method, type_ident);
828            // Use null_call_attribution: runs inside S7's property dispatch lambda.
829            let getter_call = ctx.instance_call_null_attr("self@.ptr");
830            if let Some(ref msg) = prop.deprecated {
831                // Deprecated getter: emit warning then return value
832                prop_parts.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("getter = function(self) {{ warning(\"Property @{0} is deprecated: {1}\"); {2} }}",
                prop.name, msg, getter_call))
    })format!(
833                    "getter = function(self) {{ warning(\"Property @{} is deprecated: {}\"); {} }}",
834                    prop.name, msg, getter_call
835                ));
836            } else {
837                prop_parts.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("getter = function(self) {0}",
                getter_call))
    })format!("getter = function(self) {}", getter_call));
838            }
839        }
840
841        // Generate setter (with optional frozen/deprecation handling)
842        if let Some(ref setter_ident) = prop.setter_method_ident
843            && let Some(setter_method) = find_method(setter_ident)
844        {
845            let ctx = MethodContext::new(setter_method, type_ident);
846            // Use null_call_attribution: runs inside S7's property dispatch lambda.
847            let setter_call = ctx.instance_call_null_attr("self@.ptr");
848
849            if prop.frozen {
850                // Frozen pattern: error if property was already set (non-NULL)
851                // Note: This is a simplified check; true frozen behavior would need
852                // a separate flag in the object to track if ever set
853                if let Some(ref msg) = prop.deprecated {
854                    prop_parts.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("setter = function(self, value) {{ warning(\"Property @{0} is deprecated: {1}\"); if (!is.null(self@{2})) stop(\"Property @{3} is frozen and cannot be modified\"); {4}; self }}",
                prop.name, msg, prop.name, prop.name, setter_call))
    })format!(
855                        "setter = function(self, value) {{ warning(\"Property @{} is deprecated: {}\"); if (!is.null(self@{})) stop(\"Property @{} is frozen and cannot be modified\"); {}; self }}",
856                        prop.name, msg, prop.name, prop.name, setter_call
857                    ));
858                } else {
859                    prop_parts.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("setter = function(self, value) {{ if (!is.null(self@{0})) stop(\"Property @{1} is frozen and cannot be modified\"); {2}; self }}",
                prop.name, prop.name, setter_call))
    })format!(
860                        "setter = function(self, value) {{ if (!is.null(self@{})) stop(\"Property @{} is frozen and cannot be modified\"); {}; self }}",
861                        prop.name, prop.name, setter_call
862                    ));
863                }
864            } else if let Some(ref msg) = prop.deprecated {
865                // Deprecated setter: emit warning then set value
866                prop_parts.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("setter = function(self, value) {{ warning(\"Property @{0} is deprecated: {1}\"); {2}; self }}",
                prop.name, msg, setter_call))
    })format!(
867                    "setter = function(self, value) {{ warning(\"Property @{} is deprecated: {}\"); {}; self }}",
868                    prop.name, msg, setter_call
869                ));
870            } else {
871                // Normal setter
872                prop_parts.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("setter = function(self, value) {{ {0}; self }}",
                setter_call))
    })format!(
873                    "setter = function(self, value) {{ {}; self }}",
874                    setter_call
875                ));
876            }
877        }
878
879        if prop_parts.is_empty() {
880            // This shouldn't happen, but handle gracefully
881            prop_items.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    {0} = S7::new_property()",
                prop.name))
    })format!("    {} = S7::new_property()", prop.name));
882        } else {
883            prop_items.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    {0} = S7::new_property({1})",
                prop.name, prop_parts.join(", ")))
    })format!(
884                "    {} = S7::new_property({})",
885                prop.name,
886                prop_parts.join(", ")
887            ));
888        }
889    }
890
891    if parsed_impl.r_data_accessors.enabled() {
892        lines.push("  properties = c(list(".to_string());
893    } else {
894        lines.push("  properties = list(".to_string());
895    }
896    lines.push(prop_items.join(",\n"));
897
898    // Close the properties list (or merge with sidecar properties)
899    if parsed_impl.r_data_accessors.enabled() {
900        let type_name = type_ident.to_string();
901        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  ), .rdata_properties_{0}),",
                type_name))
    })format!("  ), .rdata_properties_{}),", type_name));
902    } else {
903        lines.push("  ),".to_string());
904    }
905
906    if let Some(ctx) = parsed_impl.constructor_context() {
907        let ctor_preconditions = ctx.precondition_checks();
908        let ctor_match_arg = ctx.match_arg_prelude();
909        let params_with_ptr = if ctx.params.is_empty() {
910            ".ptr = NULL".to_string()
911        } else {
912            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}, .ptr = NULL", ctx.params))
    })format!("{}, .ptr = NULL", ctx.params)
913        };
914        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  constructor = function({0}) {{",
                params_with_ptr))
    })format!("  constructor = function({}) {{", params_with_ptr));
915        // Preconditions + match.arg only when not using .ptr shortcut
916        if !ctor_preconditions.is_empty() || !ctor_match_arg.is_empty() {
917            lines.push("    if (is.null(.ptr)) {".to_string());
918            for check in &ctor_preconditions {
919                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("      {0}", check))
    })format!("      {}", check));
920            }
921            for line in &ctor_match_arg {
922                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("      {0}", line))
    })format!("      {}", line));
923            }
924            lines.push("    }".to_string());
925        }
926        lines.push("    if (!is.null(.ptr)) {".to_string());
927        lines.push("      S7::new_object(S7::S7_object(), .ptr = .ptr)".to_string());
928        lines.push("    } else {".to_string());
929        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("      .val <- {0}",
                ctx.static_call()))
    })format!("      .val <- {}", ctx.static_call()));
930        lines.extend(crate::method_return_builder::condition_check_lines(
931            "      ",
932        ));
933        lines.push("      S7::new_object(S7::S7_object(), .ptr = .val)".to_string());
934        lines.push("    }".to_string());
935        lines.push("  }".to_string());
936    } else {
937        // No constructor: the factories and cross-class returns wrap their
938        // pointer with `Class(.ptr = val)`, and `Class()` raises an error
939        // rather than build an object without a pointer. An abstract class
940        // keeps S7's own refusal ("Can't construct an object from abstract
941        // class"). S7 requires `new_object()` in the body.
942        lines.push("  constructor = function(.ptr = NULL) {".to_string());
943        if !parsed_impl.s7_abstract {
944            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("    {0}",
                super::no_constructor_guard(&class_name)))
    })format!("    {}", super::no_constructor_guard(&class_name)));
945        }
946        lines.push("    S7::new_object(S7::S7_object(), .ptr = .ptr)".to_string());
947        lines.push("  }".to_string());
948    }
949
950    lines.push(")".to_string());
951    lines.push(String::new());
952    w.head = std::mem::take(&mut lines);
953
954    // Instance methods as S7 generics + methods
955    // Skip methods that are property getters/setters (they're handled as S7 properties)
956    for ctx in parsed_impl.instance_method_contexts() {
957        let method_ident = crate::naming::ident_name(&ctx.method.ident);
958        if property_method_idents.contains(&method_ident) {
959            continue;
960        }
961
962        let method_attrs = &ctx.method.method_attrs;
963        let target = s7_generic_target(&ctx.generic_name());
964        // The bare generic name (a `pkg::` qualifier only picks the target).
965        let generic_name = target.name().to_string();
966        // The generic in R symbol position (assignment target, first argument
967        // of `S7::method`); string literals keep the bare name (#1475).
968        let generic_symbol = crate::naming::r_def_name(&generic_name);
969
970        // The first dispatch argument names the receiver; the others are the
971        // method's leading parameters (`check_s7_dispatch` enforces this), so
972        // the method's formals start with exactly the generic's dispatch
973        // arguments, as S7 requires.
974        let dispatch_args = s7_dispatch_args(ctx.method, &target);
975        let receiver = dispatch_args[0].as_str();
976        let include_dots = !method_attrs.s7.no_dots;
977        let method_formals = ctx.instance_formals_with_receiver(receiver, include_dots);
978        let signature =
979            s7_method_signature(&class_name, dispatch_args.len(), method_attrs.s7.fallback);
980        w.member_names.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("the S7 method of `{0}` for `{1}`",
                generic_name, signature))
    })format!(
981            "the S7 method of `{generic_name}` for `{signature}`"
982        ));
983
984        // Emit the generic-doc marker BEFORE the source comment so that the
985        // write-time pass can place the standalone Rd page before the method block.
986        // This ensures the synthesised doc block (ending with NULL) is always
987        // separated from the method's roxygen comment by the source `# comment` line,
988        // preventing roxygen2 from merging the two blocks into one.
989        //
990        // Only package-owned generics get a marker; fallback methods dispatch on
991        // S7::class_any, and base operators / external generics belong elsewhere.
992        if #[allow(non_exhaustive_omitted_patterns)] match target {
    S7GenericTarget::Local(_) => true,
    _ => false,
}matches!(target, S7GenericTarget::Local(_))
993            && !method_attrs.s7.fallback
994            && !class_has_no_rd
995        {
996            let no_dots_str = if include_dots { "false" } else { "true" };
997            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!(".__MX_GENERIC_DOC__(kind=\"S7\", generic=\"{1}\", class=\"{2}\", export={3}, dispatch=\"{0}\", no_dots={4})",
                dispatch_args.join(","), generic_name, class_name,
                should_export, no_dots_str))
    })format!(
998                ".__MX_GENERIC_DOC__(kind=\"S7\", generic=\"{generic_name}\", class=\"{class_name}\", export={should_export}, dispatch=\"{}\", no_dots={no_dots_str})",
999                dispatch_args.join(",")
1000            ));
1001        }
1002
1003        lines.push(ctx.source_comment(type_ident));
1004
1005        // For fallback methods (class_any), check class before using @ to extract
1006        // the pointer. Non-S7 objects can't have @.ptr: error in R rather than
1007        // passing a wrong type to Rust (which would segfault). The error is the
1008        // shared argument error on the receiver (#1591), attributed like
1009        // `stop()` was to the method's call.
1010        let self_expr = if method_attrs.s7.fallback {
1011            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("if (inherits({0}, \"S7_object\")) {0}@.ptr else .miniextendr_arg_error(\"{0}\", paste0(\"must be an S7 object, got \", class({0})[[1L]]))",
                receiver))
    })format!(
1012                "if (inherits({receiver}, \"S7_object\")) {receiver}@.ptr else .miniextendr_arg_error(\"{receiver}\", paste0(\"must be an S7 object, got \", class({receiver})[[1L]]))"
1013            )
1014        } else {
1015            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}@.ptr", receiver))
    })format!("{receiver}@.ptr")
1016        };
1017        let call = ctx.instance_call(&self_expr);
1018
1019        // Documentation - skip if class has @noRd.
1020        // Use class-qualified @name to avoid duplicate \alias{generic} warnings
1021        // when multiple S7 classes share the same generic (e.g., get_value on both
1022        // S7TraitCounter and CounterTraitS7). The @export is replaced with
1023        // @rawNamespace to explicitly export the bare generic name.
1024        if !class_has_no_rd {
1025            let qualified_name = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}-{1}", class_name,
                generic_name))
    })format!("{}-{}", class_name, generic_name);
1026            let method_doc =
1027                MethodDocBuilder::new(&class_name, &generic_name, type_ident, &ctx.method.doc_tags)
1028                    .with_source_method(&ctx.method.ident)
1029                    .with_suppress_params()
1030                    .with_r_name(qualified_name);
1031            let mut doc_lines = method_doc.build();
1032            doc_lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @aliases {0}${1}", class_name,
                generic_name))
    })format!("#' @aliases {}${}", class_name, generic_name));
1033            // With no generic definition in between, roxygen2 would document the
1034            // `S7::method(`[[`, Class) <-` right below as an S7 method and write a
1035            // `\usage` (`x[[i, ...]]`) whose formals and operator alias this block
1036            // does not provide. Keep base-operator methods usage-free, like every
1037            // other S7 method block.
1038            if #[allow(non_exhaustive_omitted_patterns)] match target {
    S7GenericTarget::BaseOperator(_) => true,
    _ => false,
}matches!(target, S7GenericTarget::BaseOperator(_)) {
1039                doc_lines.push("#' @usage NULL".to_string());
1040            }
1041            lines.extend(doc_lines);
1042        }
1043
1044        match &target {
1045            // `[[`, `+`, ... always exist in base, and S7 resolves them itself
1046            // (the `Ops` operators through its own two-argument generics).
1047            S7GenericTarget::BaseOperator(_) => {}
1048            S7GenericTarget::External { package, name } => {
1049                // Bind the external generic under the generic's own name: when S7
1050                // registers the method for an S3 generic of a package that is not
1051                // loaded yet, it looks this binding up by name to find the package.
1052                // Calls to `name()` elsewhere in the package are unaffected, since
1053                // R's function lookup skips a non-function binding. The guard keeps
1054                // the assignment out of roxygen2's view and lets a second class's
1055                // method on the same generic reuse the binding.
1056                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("if (!base::inherits(base::get0(\"{0}\", inherits = FALSE), \"S7_external_generic\")) {{",
                name))
    })format!(
1057                    "if (!base::inherits(base::get0(\"{name}\", inherits = FALSE), \"S7_external_generic\")) {{"
1058                ));
1059                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  {1} <- S7::new_external_generic(\"{2}\", \"{3}\", {0})",
                r_chr_vector(&dispatch_args), generic_symbol, package, name))
    })format!(
1060                    "  {generic_symbol} <- S7::new_external_generic(\"{package}\", \"{name}\", {})",
1061                    r_chr_vector(&dispatch_args)
1062                ));
1063                lines.push("}".to_string());
1064            }
1065            S7GenericTarget::Local(_) => {
1066                // Use @rawNamespace to explicitly export the bare generic name.
1067                // Plain @export would export the qualified @name (e.g., "ClassName-method")
1068                // instead of the bare generic. The raw directive is not quoted by
1069                // roxygen2, so an operator generic is written `export("%op%")`.
1070                if should_export {
1071                    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @rawNamespace export({0})",
                crate::naming::r_namespace_name(&generic_name)))
    })format!(
1072                        "#' @rawNamespace export({})",
1073                        crate::naming::r_namespace_name(&generic_name)
1074                    ));
1075                }
1076                emit_s7_local_generic(&mut lines, &generic_name, &dispatch_args, include_dots);
1077            }
1078        }
1079
1080        let strategy = crate::ReturnStrategy::for_method(ctx.method);
1081        let body_lines = crate::MethodReturnBuilder::new(call)
1082            .with_strategy(strategy)
1083            .with_invisible(ctx.method.is_invisible())
1084            .with_class_name(class_name.clone())
1085            .with_return_class_from_method(ctx.method, &type_ident.to_string())
1086            .with_chain_var(receiver.to_string())
1087            .build_s7_body();
1088
1089        let what = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}.{1}", generic_name,
                class_name))
    })format!("{}.{}", generic_name, class_name);
1090        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("S7::method({0}, {1}) <- function({2}) {{",
                generic_symbol, signature, method_formals))
    })format!(
1091            "S7::method({generic_symbol}, {signature}) <- function({method_formals}) {{"
1092        ));
1093        ctx.emit_method_prelude(&mut lines, "  ", &what, &method_formals);
1094        lines.extend(body_lines);
1095        lines.push("}".to_string());
1096        if let S7GenericTarget::BaseOperator(op) = &target {
1097            lines.push(s7_base_operator_unbind(op));
1098        }
1099        lines.push(String::new());
1100
1101        // Per-class fast-path dispatch shortcut (#949).
1102        //
1103        // Alongside the S7 generic, emit a plain non-generic function
1104        // `<ClassName>_<method_name>(self, ...)` whose body is identical to the
1105        // generic's method but calls `.Call` directly — bypassing
1106        // `S7::S7_dispatch()` (class-walk + method-table lookup). On hot loops
1107        // this is several times faster than the generic. The receiver is named
1108        // `self` here (the generic names it `x`) and is wired through `self@.ptr`.
1109        //
1110        // Fallback methods dispatch on `S7::class_any`, so a per-class shortcut
1111        // is meaningless — skip them. `s7(no_shortcut)` opts a method out
1112        // explicitly (e.g. to avoid a name collision with a sidecar accessor).
1113        // An operator R name (`r_name = "[["`) has no syntactic shortcut name,
1114        // so it gets none (see `s7_shortcut_name`).
1115        let method_name = ctx.method.r_method_name();
1116        let shortcut_name = if method_attrs.s7.fallback || method_attrs.s7.no_shortcut {
1117            None
1118        } else {
1119            s7_shortcut_name(&class_name, &method_name)
1120        };
1121        if let Some(shortcut_name) = shortcut_name {
1122            let shortcut_call = ctx.instance_call(".mx_self@.ptr");
1123            let shortcut_formals =
1124                ctx.instance_formals_with_receiver(".mx_self", !method_attrs.s7.no_dots);
1125
1126            // Document the shortcut as a standalone function merged onto the
1127            // class @rdname page (same shape as static methods). Prepend a
1128            // fast-path advisory describing the dispatch bypass and the
1129            // subclass-override footgun, then reuse MethodDocBuilder for the
1130            // @name / @rdname / @source / @param scaffolding so roxygen2 emits a
1131            // complete \usage + \arguments block (no "undocumented argument"
1132            // warning). `@param .mx_self` is documented explicitly because
1133            // MethodDocBuilder skips the receiver.
1134            if !class_has_no_rd {
1135                lines.extend(shortcut_advisory_lines(&method_name, &class_name));
1136                lines.push(super::member_default(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @param .mx_self A `{0}` object.",
                class_name))
    })format!(
1137                    "#' @param .mx_self A `{}` object.",
1138                    class_name
1139                )));
1140                // `...` appears in the shortcut signature for parity with the S7
1141                // generic but MethodDocBuilder doesn't document it, so emit it
1142                // here to avoid an "Undocumented arguments" R CMD check warning.
1143                if !method_attrs.s7.no_dots {
1144                    lines.push(super::member_default(
1145                        "#' @param ... Additional arguments; ignored by the fast-path shortcut.",
1146                    ));
1147                }
1148                // Pass the method's parameter and page tags: its prose
1149                // (@title/description) is already rendered on the shared @rdname
1150                // page by the generic block above, and re-emitting it here would
1151                // duplicate it. Not its `@noRd`: the shortcut is exported with the
1152                // class (below), and an exported function without a page is an
1153                // undocumented code object. The generic block suppresses `@param`
1154                // (an S7 method assignment has no \usage), so the shortcut's \usage is
1155                // the only place those formals are documented; without them each
1156                // one fell back to `(undocumented)`. Mirrors the trait shortcut,
1157                // which forwards `param_tags`.
1158                let mx_doc = ctx.choice_param_docs();
1159                let param_tags: Vec<String> = ctx
1160                    .method
1161                    .doc_tags
1162                    .iter()
1163                    .filter(|tag| {
1164                        #[allow(non_exhaustive_omitted_patterns)] match crate::roxygen::roxygen_tag_name(tag)
    {
    Some("param" | "rdname" | "order" | "inheritParams" | "inherit") => true,
    _ => false,
}matches!(
1165                            crate::roxygen::roxygen_tag_name(tag),
1166                            Some("param" | "rdname" | "order" | "inheritParams" | "inherit")
1167                        )
1168                    })
1169                    .cloned()
1170                    .collect();
1171                let method_doc =
1172                    MethodDocBuilder::new(&class_name, &method_name, type_ident, &param_tags)
1173                        .with_source_method(&ctx.method.ident)
1174                        .with_r_params(&shortcut_formals)
1175                        .with_choice_param_docs(&mx_doc)
1176                        .with_r_name(shortcut_name.clone());
1177                lines.extend(method_doc.build());
1178            }
1179            // Export the shortcut when the class is exported, so users can reach it.
1180            if should_export {
1181                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @export {0}", shortcut_name))
    })format!("#' @export {}", shortcut_name));
1182            }
1183
1184            let strategy = crate::ReturnStrategy::for_method(ctx.method);
1185            let shortcut_body = crate::MethodReturnBuilder::new(shortcut_call)
1186                .with_strategy(strategy)
1187                .with_invisible(ctx.method.is_invisible())
1188                .with_class_name(class_name.clone())
1189                .with_return_class_from_method(ctx.method, &type_ident.to_string())
1190                .with_chain_var(".mx_self".to_string())
1191                .build_s7_body();
1192
1193            let what = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}.{1}", generic_name,
                class_name))
    })format!("{}.{}", generic_name, class_name);
1194            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} <- function({1}) {{",
                shortcut_name, shortcut_formals))
    })format!(
1195                "{shortcut_name} <- function({shortcut_formals}) {{"
1196            ));
1197            ctx.emit_method_prelude(&mut lines, "  ", &what, &shortcut_formals);
1198            lines.extend(shortcut_body);
1199            lines.push("}".to_string());
1200            lines.push(String::new());
1201        }
1202    }
1203
1204    // Static methods as regular functions
1205    for ctx in parsed_impl.static_method_contexts() {
1206        lines.push(ctx.source_comment(type_ident));
1207        let method_name = ctx.method.r_method_name();
1208        let fn_name = ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}_{1}", class_name, method_name))
    })format!("{}_{}", class_name, method_name);
1209
1210        // Skip documentation if class has @noRd
1211        if !class_has_no_rd {
1212            let mx_doc = ctx.choice_param_docs();
1213            let doc_tags = parsed_impl.static_doc_tags(
1214                ctx.method,
1215                &::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}()", fn_name))
    })format!("{fn_name}()"),
1216                &::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}()", class_name))
    })format!("{class_name}()"),
1217                true,
1218            );
1219            let method_doc =
1220                MethodDocBuilder::new(&class_name, &method_name, type_ident, &doc_tags)
1221                    .with_source_method(&ctx.method.ident)
1222                    .with_r_params(&ctx.params)
1223                    .with_choice_param_docs(&mx_doc)
1224                    .with_r_name(fn_name.clone());
1225            lines.extend(method_doc.build());
1226        }
1227        // Export static methods so users can call them (if class should be exported)
1228        if should_export {
1229            lines.push("#' @export".to_string());
1230        }
1231
1232        // A static method's R name is its only access path, so an operator
1233        // `r_name` is kept and backtick-quoted rather than dropped (#1475).
1234        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0} <- function({1}) {{",
                crate::naming::r_def_name(&fn_name), ctx.params))
    })format!(
1235            "{} <- function({}) {{",
1236            crate::naming::r_def_name(&fn_name),
1237            ctx.params
1238        ));
1239
1240        ctx.emit_method_prelude(&mut lines, "  ", &fn_name, &ctx.params);
1241
1242        let strategy = crate::ReturnStrategy::for_method(ctx.method);
1243        let return_expr = crate::MethodReturnBuilder::new(ctx.static_call())
1244            .with_strategy(strategy)
1245            .with_invisible(ctx.method.is_invisible())
1246            .with_class_name(class_name.clone())
1247            .with_return_class_from_method(ctx.method, &type_ident.to_string())
1248            .build_s7_inline();
1249        lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("  {0}", return_expr))
    })format!("  {}", return_expr));
1250
1251        lines.push("}".to_string());
1252        lines.push(String::new());
1253    }
1254
1255    // Phase 4: S7 convert() methods from Rust From/TryFrom patterns
1256    // Convert methods enable type coercion between S7 classes using S7::convert()
1257    //
1258    // Two patterns:
1259    // 1. convert_from = "OtherType" on static method: converts FROM OtherType TO this class
1260    //    Rust: fn from_other(other: OtherType) -> Self
1261    //    R: S7::method(S7::convert, list(OtherType, ThisClass)) <- function(from, to) ...
1262    //
1263    // 2. convert_to = "OtherType" on instance method: converts FROM this class TO OtherType
1264    //    Rust: fn to_other(&self) -> OtherType
1265    //    R: S7::method(S7::convert, list(ThisClass, OtherType)) <- function(from, to) ...
1266
1267    for method in &parsed_impl.methods {
1268        if !method.should_include() {
1269            continue;
1270        }
1271        let attrs = &method.method_attrs;
1272
1273        // Handle convert_from (static method pattern)
1274        // S7 convert signature is function(from, to) - one parameter for the source object
1275        if let Some(ref from_type) = attrs.s7.convert_from {
1276            w.member_names.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("the S7 `convert()` method from `{0}` to `{1}`",
                from_type, class_name))
    })format!(
1277                "the S7 `convert()` method from `{from_type}` to `{class_name}`"
1278            ));
1279            let ctx = MethodContext::new(method, type_ident)
1280                .with_impl_preconditions(parsed_impl.preconditions);
1281
1282            // Documentation for convert method (skip if class has @noRd)
1283            if !class_has_no_rd {
1284                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @name convert-{0}-to-{1}",
                from_type, class_name))
    })format!("#' @name convert-{}-to-{}", from_type, class_name));
1285                // Convert methods emit scaffolding only (no method prose), but a
1286                // method-level `@rdname` still decides which page they land on;
1287                // a split page needs its own `@title` (see `MethodDocBuilder`).
1288                super::push_member_rdname(&mut lines, &method.doc_tags, &class_name);
1289                if crate::roxygen::rdname_value(&method.doc_tags).is_some()
1290                    && !crate::roxygen::has_roxygen_tag(&method.doc_tags, "title")
1291                {
1292                    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @title convert-{0}-to-{1}",
                from_type, class_name))
    })format!("#' @title convert-{}-to-{}", from_type, class_name));
1293                }
1294                crate::roxygen::push_order_after_topic_blocks(
1295                    &mut lines,
1296                    &method.doc_tags,
1297                    &class_name,
1298                );
1299                lines.extend(crate::roxygen::method_source_tag(type_ident, &method.ident));
1300                // The generic belongs to S7. Giving every class page its
1301                // `convert` alias duplicates that alias across the package.
1302                // Describe the registration here; ordinary Rust shortcuts
1303                // retain their own usage and argument documentation.
1304                lines.push("#' @usage NULL".to_string());
1305                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @section Conversion from `{0}`:",
                class_ref_or_verbatim(from_type)))
    })format!(
1306                    "#' @section Conversion from `{}`:",
1307                    class_ref_or_verbatim(from_type)
1308                ));
1309                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' `S7::convert(from, {0})` converts a source object to this class.",
                class_name))
    })format!(
1310                    "#' `S7::convert(from, {})` converts a source object to this class.",
1311                    class_name
1312                ));
1313                lines.push("#' See [S7::convert()] for the generic's arguments.".to_string());
1314            }
1315
1316            // Generate: S7::method(S7::convert, list(FromType, ThisClass)) <- function(from, to, ...) ...
1317            // The convert_from method takes the source object as its sole parameter
1318            // We pass from@.ptr to extract the ExternalPtr from the S7 object
1319            let call_with_from = crate::r_wrapper_builder::DotCallBuilder::new(&ctx.c_ident)
1320                .with_self("from@.ptr")
1321                .build();
1322
1323            let strategy = crate::ReturnStrategy::for_method(method);
1324            let return_expr = crate::MethodReturnBuilder::new(call_with_from)
1325                .with_strategy(strategy)
1326                .with_invisible(method.is_invisible())
1327                .with_class_name(class_name.clone())
1328                .with_return_class_from_method(method, &type_ident.to_string())
1329                .build_s7_inline();
1330
1331            // Use imported `convert` - requires `@importFrom S7 convert` in package.
1332            // from_type is a cross-reference → placeholder so the resolver can look it up.
1333            // Method signature includes `...` to match the S7 `convert` generic
1334            // (function(from, to, ...)) and silence R CMD check codoc warnings.
1335            let from_type_ref = class_ref_or_verbatim(from_type);
1336            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("S7::method(convert, list({0}, {1})) <- function(from, to, ...) {2}",
                from_type_ref, class_name, return_expr))
    })format!(
1337                "S7::method(convert, list({}, {})) <- function(from, to, ...) {}",
1338                from_type_ref, class_name, return_expr
1339            ));
1340            lines.push(String::new());
1341        }
1342
1343        // Handle convert_to (instance method pattern)
1344        // S7 convert signature is function(from, to) - self becomes from
1345        if let Some(ref to_type) = attrs.s7.convert_to {
1346            w.member_names.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("the S7 `convert()` method from `{0}` to `{1}`",
                class_name, to_type))
    })format!(
1347                "the S7 `convert()` method from `{class_name}` to `{to_type}`"
1348            ));
1349            let ctx = MethodContext::new(method, type_ident)
1350                .with_impl_preconditions(parsed_impl.preconditions);
1351
1352            // Documentation for convert method (skip if class has @noRd)
1353            if !class_has_no_rd {
1354                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @name convert-{0}-to-{1}",
                class_name, to_type))
    })format!("#' @name convert-{}-to-{}", class_name, to_type));
1355                super::push_member_rdname(&mut lines, &method.doc_tags, &class_name);
1356                if crate::roxygen::rdname_value(&method.doc_tags).is_some()
1357                    && !crate::roxygen::has_roxygen_tag(&method.doc_tags, "title")
1358                {
1359                    lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @title convert-{0}-to-{1}",
                class_name, to_type))
    })format!("#' @title convert-{}-to-{}", class_name, to_type));
1360                }
1361                crate::roxygen::push_order_after_topic_blocks(
1362                    &mut lines,
1363                    &method.doc_tags,
1364                    &class_name,
1365                );
1366                lines.extend(crate::roxygen::method_source_tag(type_ident, &method.ident));
1367                lines.push("#' @usage NULL".to_string());
1368                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' @section Conversion to `{0}`:",
                class_ref_or_verbatim(to_type)))
    })format!(
1369                    "#' @section Conversion to `{}`:",
1370                    class_ref_or_verbatim(to_type)
1371                ));
1372                lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("#\' `S7::convert(from, {0})` converts an object of this class to the target class.",
                class_ref_or_verbatim(to_type)))
    })format!(
1373                    "#' `S7::convert(from, {})` converts an object of this class to the target class.",
1374                    class_ref_or_verbatim(to_type)
1375                ));
1376                lines.push("#' See [S7::convert()] for the generic's arguments.".to_string());
1377            }
1378
1379            // Generate: S7::method(convert, list(ThisClass, ToType)) <- function(from, to, ...) ...
1380            // The convert_to method is an instance method where self is mapped to from@.ptr
1381            let call = crate::r_wrapper_builder::DotCallBuilder::new(&ctx.c_ident)
1382                .with_self("from@.ptr")
1383                .build();
1384
1385            // to_type is a cross-reference → placeholder for resolver.
1386            // We also pass the placeholder to MethodReturnBuilder so the
1387            // emitted `ToType(.ptr = <result>)` uses the resolved name.
1388            let to_type_ref = class_ref_or_verbatim(to_type);
1389
1390            // Force ReturnSelf strategy for convert methods since they return S7 class types
1391            // that need to be wrapped: ToType(.ptr = <result>)
1392            let return_expr = crate::MethodReturnBuilder::new(call)
1393                .with_strategy(crate::ReturnStrategy::ReturnSelf)
1394                .with_class_name(to_type_ref.clone())
1395                .with_invisible(method.is_invisible())
1396                .with_return_class_from_method(method, &type_ident.to_string())
1397                .build_s7_inline();
1398
1399            // Use imported `convert` - requires `@importFrom S7 convert` in package.
1400            // Method signature includes `...` to match the S7 `convert` generic
1401            // (function(from, to, ...)) and silence R CMD check codoc warnings.
1402            lines.push(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("S7::method(convert, list({0}, {1})) <- function(from, to, ...) {2}",
                class_name, to_type_ref, return_expr))
    })format!(
1403                "S7::method(convert, list({}, {})) <- function(from, to, ...) {}",
1404                class_name, to_type_ref, return_expr
1405            ));
1406            lines.push(String::new());
1407        }
1408    }
1409
1410    w.members = lines;
1411    w
1412}
1413
1414/// The S7 class as one string: [`generate_s7_class`], rendered.
1415#[cfg(test)]
1416pub fn generate_s7_r_wrapper(parsed_impl: &ParsedImpl) -> String {
1417    generate_s7_class(parsed_impl).render()
1418}