Skip to main content

miniextendr_macros/miniextendr_impl/
vctrs_class.rs

1//! Vctrs class R wrapper generator.
2//!
3//! Generates an S3 class that **integrates with the tidyverse `vctrs`
4//! package**: emits a `new_<class>` wrapper around `vctrs::new_vctr` /
5//! `new_rcrd` / `new_list_of` (selected via `VctrsKind`), plus the standard
6//! `vec_ptype2` / `vec_cast` self-coercion methods so the type composes
7//! cleanly inside `tibble`, `dplyr`, and `tidyr`. Pick vctrs when your Rust
8//! type is "a vector of X" and you want first-class tibble columns; use
9//! plain S3/S7 for scalar-like objects.
10
11use super::{ParsedImpl, VctrsKind};
12
13/// Generates the complete R wrapper string for a vctrs-compatible S3 class.
14///
15/// This is used when an `impl` block is annotated with `#[miniextendr(vctrs)]`.
16/// Unlike the `#[derive(Vctrs)]` macro (which generates standalone S3 methods from
17/// struct attributes), this generator produces class wrappers from `impl` block methods.
18///
19/// Produces the following R code:
20/// - Constructor: `new_<class>(...)` that calls the Rust `new` constructor, then wraps
21///   the result with `vctrs::new_vctr()`, `vctrs::new_rcrd()`, or `vctrs::new_list_of()`
22///   depending on the `VctrsKind`
23/// - `vec_ptype_abbr.<class>`: compact abbreviation for printing (if `abbr` is specified)
24/// - `vec_ptype2.<class>.<class>`: self-coercion prototype (returns empty typed vector)
25/// - `vec_cast.<class>.<class>`: identity cast (returns `x` unchanged)
26/// - Instance methods: S3 generics + `<generic>.<class>` methods, with support for
27///   vctrs protocol overrides via `#[miniextendr(vctrs_protocol = "...")]` and
28///   double-dispatch class suffixes via `#[miniextendr(class = "...")]`
29/// - Static methods: regular functions named `<class>_<method>(...)`
30///
31/// Roxygen2 documentation and `@importFrom vctrs ...` tags are generated automatically.
32/// A class-level `@noRd` (or a plain `noexport` without `internal`) suppresses the Rd
33/// contribution of every block — self-coercion methods, instance-method generics, and
34/// instance/static method docs all collapse to `@noRd` (#1180), like the other five
35/// class-system generators. S3 `S3method()` dispatch registration (`@method` +
36/// `@export`) is kept unconditionally: vctrs generics dispatch from the vctrs
37/// namespace, so an unregistered method would leave even a gated class non-functional
38/// (and roxygen2 warns on any recognized-but-unregistered S3 method).
39pub fn generate_vctrs_r_wrapper(parsed_impl: &ParsedImpl) -> String {
40    use crate::r_class_formatter::{
41        ClassDocBuilder, MethodDocBuilder, ParsedImplExt, emit_s3_generic_guard,
42        should_export_from_tags,
43    };
44
45    let class_name = parsed_impl.class_name();
46    let type_ident = &parsed_impl.type_ident;
47    let class_doc_tags = &parsed_impl.doc_tags;
48    let vctrs_attrs = &parsed_impl.vctrs_attrs;
49    let should_export =
50        should_export_from_tags(class_doc_tags, parsed_impl.noexport || parsed_impl.internal);
51    // Check if the class has @noRd — if so, skip method documentation (#1180). A
52    // plain `noexport` (without `internal`) is folded into the gate too: it must
53    // suppress Rd contribution entirely (audit A10 semantics), matching the other
54    // five class generators. `internal` keeps full docs (the constructor's
55    // ClassDocBuilder adds `@keywords internal`); `should_export` (above) gates
56    // the export()-shaped surface (custom S3 generics) and is always false when
57    // this gate is true.
58    //
59    // S3-method-shaped emissions (`vec_ptype_abbr`/`vec_ptype2`/`vec_cast`,
60    // protocol overrides, instance methods) keep their `@method` + `@export`
61    // pair unconditionally: with `@method`, `@export` produces an `S3method()`
62    // dispatch registration in NAMESPACE — not an `export()` — and both
63    // roxygen2 (`warn_missing_s3_exports`) and vctrs itself (generics dispatch
64    // from the vctrs namespace) require registered methods. A gated class stays
65    // hidden (no Rd page, no `export()` entries) but remains a functioning vctr.
66    let class_has_no_rd = crate::roxygen::has_roxygen_tag(class_doc_tags, "noRd")
67        || (parsed_impl.noexport && !parsed_impl.internal);
68
69    // Constructor name follows vctrs convention: new_<class>
70    let ctor_name = format!("new_{}", class_name.to_lowercase());
71
72    let mut lines = Vec::new();
73
74    // Constructor with combined class and constructor documentation
75    if let Some(ctx) = parsed_impl.constructor_context() {
76        lines.push(ctx.source_comment(type_ident));
77        let mut ctor_doc_tags = Vec::new();
78        ctor_doc_tags.extend(class_doc_tags.iter().cloned());
79        ctor_doc_tags.extend(ctx.method.doc_tags.iter().cloned());
80
81        lines.extend(
82            ClassDocBuilder::new(&class_name, type_ident, &ctor_doc_tags, "vctrs S3")
83                .with_imports("@importFrom vctrs new_vctr new_rcrd new_list_of vec_ptype2 vec_cast vec_ptype_abbr")
84                .with_export_control(parsed_impl.internal, parsed_impl.noexport)
85                .build(),
86        );
87        // Inject lifecycle imports from methods into class-level roxygen block
88        if let Some(lc_import) = crate::lifecycle::collect_lifecycle_imports(
89            parsed_impl
90                .methods
91                .iter()
92                .filter_map(|m| m.method_attrs.lifecycle.as_ref()),
93        ) {
94            let insert_pos = lines.len().saturating_sub(1);
95            lines.insert(insert_pos, format!("#' {}", lc_import));
96        }
97
98        // Generate constructor body based on vctrs kind
99        lines.push(format!("{} <- function({}) {{", ctor_name, ctx.params));
100        for check in ctx.precondition_checks() {
101            lines.push(format!("  {}", check));
102        }
103        // Inject match.arg validation for match_arg/choices params
104        for line in ctx.match_arg_prelude() {
105            lines.push(format!("  {}", line));
106        }
107        lines.push(format!("  .val <- {}", ctx.static_call()));
108        lines.extend(crate::method_return_builder::condition_check_lines("  "));
109        lines.push("  data <- .val".to_string());
110
111        match vctrs_attrs.kind {
112            VctrsKind::Vctr => {
113                // Build new_vctr call with optional inherit_base_type
114                let inherit_arg = match vctrs_attrs.inherit_base_type {
115                    Some(true) => ", inherit_base_type = TRUE",
116                    Some(false) => ", inherit_base_type = FALSE",
117                    None => "",
118                };
119                lines.push(format!(
120                    "  vctrs::new_vctr(data, class = \"{}\"{})",
121                    class_name, inherit_arg
122                ));
123            }
124            VctrsKind::Rcrd => {
125                // Record type - data should be a list
126                lines.push(format!(
127                    "  vctrs::new_rcrd(data, class = \"{}\")",
128                    class_name
129                ));
130            }
131            VctrsKind::ListOf => {
132                // list_of - needs ptype
133                let ptype_arg = vctrs_attrs
134                    .ptype
135                    .as_ref()
136                    .map(|p| format!(", ptype = {}", p))
137                    .unwrap_or_default();
138                lines.push(format!(
139                    "  vctrs::new_list_of(data, class = \"{}\"{})",
140                    class_name, ptype_arg
141                ));
142            }
143        }
144        lines.push("}".to_string());
145        lines.push(String::new());
146    }
147
148    // vec_ptype_abbr for compact printing (if abbr is specified)
149    if let Some(abbr) = &vctrs_attrs.abbr {
150        if class_has_no_rd {
151            // Gated class (@noRd / plain noexport): no Rd contribution — keep
152            // only the @method + @export S3method() registration pair.
153            lines.push("#' @noRd".to_string());
154        } else {
155            lines.push(format!("#' @rdname {}", class_name));
156        }
157        lines.push(format!("#' @method vec_ptype_abbr {}", class_name));
158        lines.push("#' @export".to_string());
159        lines.push(format!(
160            "vec_ptype_abbr.{} <- function(x, ...) \"{}\"",
161            class_name, abbr
162        ));
163        lines.push(String::new());
164    }
165
166    // Self-coercion methods (required for vctrs to work properly)
167    // vec_ptype2.<class>.<class> - returns prototype for combining same types
168    if class_has_no_rd {
169        lines.push("#' @noRd".to_string());
170        lines.push(format!(
171            "#' @method vec_ptype2 {}.{}",
172            class_name, class_name
173        ));
174    } else {
175        lines.push(format!("#' @rdname {}", class_name));
176        lines.push(format!(
177            "#' @method vec_ptype2 {}.{}",
178            class_name, class_name
179        ));
180        lines.push(format!("#' @param x A {} vector.", class_name));
181        lines.push(format!("#' @param y A {} vector.", class_name));
182        lines.push("#' @param ... Additional arguments (unused).".to_string());
183    }
184    lines.push("#' @export".to_string());
185    match vctrs_attrs.kind {
186        VctrsKind::Vctr => {
187            let base_type = vctrs_attrs
188                .base
189                .as_ref()
190                .map(|b| format!("{}()", b))
191                .unwrap_or_else(|| "double()".to_string());
192            let inherit_arg = match vctrs_attrs.inherit_base_type {
193                Some(true) => ", inherit_base_type = TRUE",
194                Some(false) => ", inherit_base_type = FALSE",
195                None => "",
196            };
197            lines.push(format!(
198                "vec_ptype2.{c}.{c} <- function(x, y, ...) vctrs::new_vctr({base}, class = \"{c}\"{inherit})",
199                c = class_name,
200                base = base_type,
201                inherit = inherit_arg
202            ));
203        }
204        VctrsKind::Rcrd => {
205            // For records, return empty record with same field structure
206            lines.push(format!(
207                "vec_ptype2.{c}.{c} <- function(x, y, ...) x[0]",
208                c = class_name
209            ));
210        }
211        VctrsKind::ListOf => {
212            let ptype_arg = vctrs_attrs
213                .ptype
214                .as_ref()
215                .map(|p| format!(", ptype = {}", p))
216                .unwrap_or_default();
217            lines.push(format!(
218                "vec_ptype2.{c}.{c} <- function(x, y, ...) vctrs::new_list_of(list(), class = \"{c}\"{ptype})",
219                c = class_name,
220                ptype = ptype_arg
221            ));
222        }
223    }
224    lines.push(String::new());
225
226    // vec_cast.<class>.<class> - identity cast (no-op for same type)
227    if class_has_no_rd {
228        lines.push("#' @noRd".to_string());
229        lines.push(format!("#' @method vec_cast {}.{}", class_name, class_name));
230    } else {
231        lines.push(format!("#' @rdname {}", class_name));
232        lines.push(format!("#' @method vec_cast {}.{}", class_name, class_name));
233        lines.push(format!("#' @param x A {} vector to cast.", class_name));
234        lines.push(format!("#' @param to A {} prototype.", class_name));
235        lines.push("#' @param ... Additional arguments (unused).".to_string());
236    }
237    lines.push("#' @export".to_string());
238    lines.push(format!(
239        "vec_cast.{c}.{c} <- function(x, to, ...) x",
240        c = class_name
241    ));
242    lines.push(String::new());
243
244    // Instance methods as S3 generics + methods
245    for ctx in parsed_impl.instance_method_contexts() {
246        lines.push(ctx.source_comment(type_ident));
247        // vctrs protocol override: use the protocol name as the S3 generic
248        let is_protocol = ctx.method.method_attrs.vctrs_protocol.is_some();
249        let generic_name = if let Some(ref proto) = ctx.method.method_attrs.vctrs_protocol {
250            proto.clone()
251        } else {
252            ctx.generic_name()
253        };
254        // Use custom class suffix if provided (for double-dispatch patterns like vec_ptype2.a.b)
255        let method_class_suffix = ctx
256            .class_suffix()
257            .map(|s| s.to_string())
258            .unwrap_or_else(|| class_name.clone());
259        let s3_method_name = format!("{}.{}", generic_name, method_class_suffix);
260        let full_params = ctx.instance_formals(true); // adds x, ..., params
261
262        // Only create the S3 generic if no generic/class override was provided
263        // vctrs protocol methods use existing generics from the vctrs package
264        if !is_protocol && !ctx.has_generic_override() && !ctx.has_class_override() {
265            if class_has_no_rd {
266                lines.push("#' @noRd".to_string());
267            } else {
268                lines.push(format!("#' @title S3 generic for `{}`", generic_name));
269                lines.push(format!("#' @description S3 generic for `{}`", generic_name));
270                lines.push(format!("#' @rdname {}", class_name));
271                // Use class-qualified name to avoid duplicate alias when multiple
272                // classes define the same S3 generic.
273                lines.push(format!("#' @name {}.{}", generic_name, class_name));
274                lines.push("#' @param x An object".to_string());
275                lines.push("#' @param ... Additional arguments passed to methods".to_string());
276                lines.push(crate::roxygen::method_source_tag(
277                    type_ident,
278                    &ctx.method.ident,
279                ));
280                if should_export {
281                    // Explicit name on @export: the generic is wrapped in
282                    // `if (!exists(...))`, which roxygen2 can't introspect, and
283                    // the @name tag above is the class-qualified form. Without
284                    // an explicit target, roxygen2 attaches the export to the
285                    // next parseable function (the S3 method) — see s3_class.rs.
286                    lines.push(format!("#' @export {}", generic_name));
287                }
288            }
289            lines.push(emit_s3_generic_guard(&generic_name));
290            lines.push(String::new());
291        }
292
293        // Then create the S3 method
294        let qualified_name = format!("{}.{}", generic_name, method_class_suffix);
295        let mx_doc = ctx.match_arg_doc_placeholders();
296        let method_doc =
297            MethodDocBuilder::new(&class_name, &generic_name, type_ident, &ctx.method.doc_tags)
298                .with_r_params(&ctx.params)
299                .with_match_arg_doc_placeholders(&mx_doc)
300                .with_r_name(qualified_name)
301                .with_class_no_rd(class_has_no_rd);
302        lines.extend(method_doc.build());
303        lines.push(format!(
304            "#' @method {} {}",
305            generic_name, method_class_suffix
306        ));
307        lines.push("#' @export".to_string());
308        lines.push(format!(
309            "{} <- function({}) {{",
310            s3_method_name, full_params
311        ));
312
313        let what = format!("{}.{}", generic_name, class_name);
314        ctx.emit_method_prelude(&mut lines, "  ", &what);
315
316        let call = ctx.instance_call("x");
317        let strategy = crate::ReturnStrategy::for_method(ctx.method);
318        let return_builder = crate::MethodReturnBuilder::new(call)
319            .with_strategy(strategy)
320            .with_class_name(class_name.clone())
321            .with_chain_var("x".to_string());
322        lines.extend(return_builder.build_s3_body());
323
324        lines.push("}".to_string());
325        lines.push(String::new());
326    }
327
328    // Static methods as regular functions, or as vctrs protocol S3 methods when
329    // `#[miniextendr(vctrs(protocol))]` is set (e.g. `vctrs(format)` → `format.<Class>`).
330    for ctx in parsed_impl.static_method_contexts() {
331        lines.push(ctx.source_comment(type_ident));
332
333        let is_protocol = ctx.method.method_attrs.vctrs_protocol.is_some();
334        let fn_name = if let Some(ref proto) = ctx.method.method_attrs.vctrs_protocol {
335            // vctrs protocol override: emit as `<protocol>.<Class>` S3 method
336            format!("{}.{}", proto, class_name)
337        } else {
338            let method_name = ctx.method.r_method_name();
339            format!("{}_{}", class_name.to_lowercase(), method_name)
340        };
341        let r_name = fn_name.clone();
342
343        let mx_doc = ctx.match_arg_doc_placeholders();
344        let method_name = ctx.method.r_method_name();
345        if is_protocol {
346            let proto = ctx.method.method_attrs.vctrs_protocol.as_ref().unwrap();
347            let method_doc =
348                MethodDocBuilder::new(&class_name, &method_name, type_ident, &ctx.method.doc_tags)
349                    .with_r_params(&ctx.params)
350                    .with_match_arg_doc_placeholders(&mx_doc)
351                    .with_r_name(r_name.clone())
352                    .with_class_no_rd(class_has_no_rd);
353            lines.extend(method_doc.build());
354            lines.push(format!("#' @method {} {}", proto, class_name));
355            lines.push("#' @export".to_string());
356        } else {
357            let method_doc =
358                MethodDocBuilder::new(&class_name, &method_name, type_ident, &ctx.method.doc_tags)
359                    .with_r_params(&ctx.params)
360                    .with_match_arg_doc_placeholders(&mx_doc)
361                    .with_r_name(r_name.clone())
362                    .with_class_no_rd(class_has_no_rd);
363            lines.extend(method_doc.build());
364        }
365
366        // Protocol methods accept `...` so `format(x, nsmall = 2)` and similar
367        // S3 dispatch calls with extra arguments don't error with "unused argument".
368        // The `...` is silently dropped; the underlying Rust function has a fixed signature.
369        let formals = if is_protocol {
370            format!("{}, ...", ctx.params)
371        } else {
372            ctx.params.to_string()
373        };
374        lines.push(format!("{} <- function({}) {{", fn_name, formals));
375
376        ctx.emit_method_prelude(&mut lines, "  ", &fn_name);
377
378        let strategy = crate::ReturnStrategy::for_method(ctx.method);
379        let return_builder = crate::MethodReturnBuilder::new(ctx.static_call())
380            .with_strategy(strategy)
381            .with_class_name(class_name.clone());
382        lines.extend(return_builder.build_s3_body());
383
384        lines.push("}".to_string());
385        lines.push(String::new());
386    }
387
388    lines.join("\n")
389}