Skip to main content

miniextendr_macros/
r_wrapper_builder.rs

1//! Shared utilities for building R wrapper code.
2//!
3//! This module provides builders for constructing R function signatures and call arguments
4//! consistently across both standalone functions and impl methods.
5//!
6//! ## Key Components
7//!
8//! - [`RArgumentBuilder`]: Builds R formals and `.Call()` arguments from Rust signatures
9//! - [`DotCallBuilder`]: Formats `.Call()` invocations with proper argument handling
10//! - [`RoxygenBuilder`]: Generates roxygen2 documentation tags
11//!
12//! ## Usage
13//!
14//! ```ignore
15//! // Build R function signature
16//! let formals = build_r_formals_from_sig(&method.sig, &defaults);
17//! let call_args = build_r_call_args_from_sig(&method.sig);
18//!
19//! // Build .Call() invocation
20//! let call = DotCallBuilder::new("C_MyType__method")
21//!     .with_self("self")
22//!     .with_args(&["x", "y"])
23//!     .build();
24//!
25//! // Build roxygen tags
26//! let tags = RoxygenBuilder::new("MyType")
27//!     .name("method")
28//!     .rdname("MyType")
29//!     .export()
30//!     .build();
31//! ```
32
33/// Normalizes Rust argument identifiers for R.
34///
35/// - Leading `_` → stripped (Rust convention for unused params)
36/// - Leading `__` → stripped
37/// - Otherwise → unchanged
38///
39/// # Examples
40/// - `_x` → `x`
41/// - `_to` → `to`
42/// - `__field` → `field`
43/// - `value` → `value`
44///
45/// Note: We strip underscores rather than prefixing "unused" because R callers
46/// (like vctrs) may use named arguments that must match the original name.
47pub fn normalize_r_arg_ident(rust_ident: &syn::Ident) -> syn::Ident {
48    syn::Ident::new(
49        &normalize_r_arg_string(&rust_ident.to_string()),
50        rust_ident.span(),
51    )
52}
53
54/// String form of [`normalize_r_arg_ident`] that skips the `syn::Ident` round-trip.
55///
56/// Most callers feed the result into `format!`/`HashMap` keys and immediately
57/// `.to_string()` the returned ident — this avoids that allocation pair.
58pub fn normalize_r_arg_string(name: &str) -> String {
59    let normalized = name.trim_start_matches('_');
60    if normalized.is_empty() {
61        "arg".to_string()
62    } else {
63        normalized.to_string()
64    }
65}
66
67/// Split a comma-separated choices list (as given to `choices(param = "a, b, c")`)
68/// into individual trimmed entries. Surrounding double-quotes are tolerated so
69/// users can spell the list either way: `"a, b"` or `"\"a\", \"b\""`.
70///
71/// Shared by the inherent-impl (`miniextendr_impl.rs`) and trait-impl
72/// (`miniextendr_impl_trait/vtable.rs`) `choices(...)` attribute parsers so the
73/// two independently-maintained parsers can't drift on quoting/whitespace rules.
74pub(crate) fn split_choice_list(raw: &str) -> Vec<String> {
75    raw.split(',')
76        .map(|s| s.trim().trim_matches('"').to_string())
77        .filter(|s| !s.is_empty())
78        .collect()
79}
80
81/// Builder for R function formal parameters and call arguments.
82///
83/// Handles:
84/// - Underscore normalization (`_x` → `unused_x`)
85/// - Unit type defaults (`()` → `= NULL`)
86/// - Dots (`...`) with optional naming
87/// - Consistent formatting across function and method wrappers
88pub struct RArgumentBuilder<'a> {
89    /// The function's input parameters from the parsed Rust signature.
90    inputs: &'a syn::punctuated::Punctuated<syn::FnArg, syn::token::Comma>,
91    /// If true, last parameter is treated as dots (`...`).
92    has_dots: bool,
93    /// Optional named binding for dots (e.g., `args: ...` in Rust becomes a named dots param).
94    /// The name is normalized (leading underscores stripped) but only used on the Rust side;
95    /// R formals always emit plain `...`.
96    named_dots: Option<String>,
97    /// If true, skip the first parameter (used for `self`/`&self` in method wrappers,
98    /// since the self argument is handled separately by [`DotCallBuilder::with_self`]).
99    skip_first: bool,
100    /// Parameter default values from `#[miniextendr(default = "...")]` attributes.
101    /// Keys are normalized R parameter names, values are R expressions emitted verbatim
102    /// (e.g., `"1L"`, `"c(1, 2, 3)"`, `"NULL"`).
103    defaults: std::collections::HashMap<String, String>,
104}
105
106impl<'a> RArgumentBuilder<'a> {
107    /// Create a new builder for the given function inputs.
108    pub fn new(inputs: &'a syn::punctuated::Punctuated<syn::FnArg, syn::token::Comma>) -> Self {
109        let named_dots = crate::miniextendr_fn::trailing_dots_ident(inputs)
110            .map(|ident| normalize_r_arg_ident(&ident).to_string());
111        Self {
112            inputs,
113            has_dots: named_dots.is_some(),
114            named_dots,
115            skip_first: false,
116            defaults: std::collections::HashMap::new(),
117        }
118    }
119
120    /// Add parameter defaults from `#[miniextendr(default = "...")]` attributes.
121    ///
122    /// Keys are normalized R parameter names (after underscore stripping),
123    /// values are R expression strings emitted verbatim into formals.
124    pub fn with_defaults(mut self, defaults: std::collections::HashMap<String, String>) -> Self {
125        self.defaults = defaults;
126        self
127    }
128
129    /// Mark the last parameter as dots (`...`).
130    ///
131    /// If `named_dots` is `Some("name")`, the dots have a Rust-side binding
132    /// (from `name: ...` syntax). The name is normalized but only affects the
133    /// Rust side -- R formals always emit plain `...`.
134    pub fn with_dots(mut self, named_dots: Option<String>) -> Self {
135        self.has_dots = true;
136        self.named_dots = named_dots.map(|s| {
137            normalize_r_arg_ident(&syn::Ident::new(&s, proc_macro2::Span::call_site())).to_string()
138        });
139        self
140    }
141
142    /// Skip the first parameter (for instance methods with `self`).
143    pub fn skip_first(mut self) -> Self {
144        self.skip_first = true;
145        self
146    }
147
148    /// Build R formal parameters string (for function signature).
149    ///
150    /// # Returns
151    /// Comma-separated parameter list, e.g., `"x, y = NULL, ..."`
152    ///
153    /// This method handles R-style defaults (like `1L`, `c(1,2,3)`) that aren't
154    /// valid Rust syntax by outputting them directly as strings.
155    pub fn build_formals(&self) -> String {
156        let mut formals = Vec::new();
157        let last_idx = self.inputs.len().saturating_sub(1);
158
159        for (idx, input) in self.inputs.iter().enumerate() {
160            // Skip first if requested (for self in methods)
161            if self.skip_first && idx == 0 {
162                continue;
163            }
164
165            let pat_type = match input {
166                syn::FnArg::Typed(pt) => pt,
167                syn::FnArg::Receiver(_) => continue, // Skip self receivers
168            };
169
170            // Handle dots (must be last)
171            // Note: In R, `...` cannot have a name/default in formals - it must be just `...`
172            // The named_dots is only used on the Rust side. R formals always use plain `...`
173            if self.has_dots && idx == last_idx {
174                formals.push("...".to_string());
175                continue;
176            }
177
178            // Extract and normalize argument name
179            let arg_ident = match pat_type.pat.as_ref() {
180                syn::Pat::Ident(pat_ident) => normalize_r_arg_ident(&pat_ident.ident),
181                _ => continue,
182            };
183
184            // Check for user-specified default value
185            if let Some(default_val) = self.defaults.get(&arg_ident.to_string()) {
186                // User provided default via #[miniextendr(default = "...")]
187                // Output directly as string - supports R-style defaults like "1L", "c(1,2,3)"
188                formals.push(format!("{} = {}", arg_ident, default_val));
189                continue;
190            }
191
192            // Add default for unit types
193            match pat_type.ty.as_ref() {
194                syn::Type::Tuple(t) if t.elems.is_empty() => {
195                    formals.push(format!("{} = NULL", arg_ident));
196                }
197                _ => {
198                    formals.push(arg_ident.to_string());
199                }
200            }
201        }
202
203        formals.join(", ")
204    }
205
206    /// Build R call arguments string (for `.Call()` invocation).
207    ///
208    /// # Returns
209    /// Comma-separated argument list, e.g., `"x, y, list(...)"`
210    pub fn build_call_args(&self) -> String {
211        self.build_call_args_vec().join(", ")
212    }
213
214    /// Build R call arguments as a `Vec<String>`.
215    ///
216    /// Each element is a single argument expression. Dots parameters become
217    /// `"list(...)"` to capture variadic args as an R list for the `.Call()` interface.
218    pub fn build_call_args_vec(&self) -> Vec<String> {
219        let mut call_args = Vec::new();
220        let last_idx = self.inputs.len().saturating_sub(1);
221
222        for (idx, input) in self.inputs.iter().enumerate() {
223            // Skip first if requested (for self in methods)
224            if self.skip_first && idx == 0 {
225                continue;
226            }
227
228            let syn::FnArg::Typed(pat_type) = input else {
229                continue;
230            };
231
232            // Handle dots special case
233            // Always use list(...) since R formals always have plain `...`
234            if self.has_dots && idx == last_idx {
235                call_args.push("list(...)".to_string());
236                continue;
237            }
238
239            // Extract and normalize argument name
240            let arg_ident = match pat_type.pat.as_ref() {
241                syn::Pat::Ident(pat_ident) => normalize_r_arg_ident(&pat_ident.ident),
242                _ => continue,
243            };
244
245            // `Missing<T>`: forward true missingness as the `R_MissingArg`
246            // sentinel, produced *at the argument position*. A binding holding
247            // the sentinel errors on symbol lookup ("argument is missing, with
248            // no default"), so the former `if (missing(x)) x <- quote(expr=)`
249            // prelude broke every truly-missing call. (`Missing<T>` + user
250            // default is rejected at macro parse time, so no default-shadowing
251            // concern here.)
252            if is_missing_type(pat_type.ty.as_ref()) {
253                call_args.push(format!(
254                    "if (missing({p})) quote(expr=) else {p}",
255                    p = arg_ident
256                ));
257                continue;
258            }
259
260            call_args.push(arg_ident.to_string());
261        }
262
263        call_args
264    }
265}
266
267/// Build R formal parameters from a Rust function signature, with optional defaults.
268///
269/// Automatically skips `self`/`&self` receivers. `Missing<T>` parameters without
270/// user-provided defaults appear as bare formals (no default value); the
271/// `R_MissingArg` sentinel forwarding is emitted inline in the `.Call()` args
272/// (see [`RArgumentBuilder::build_call_args_vec`]).
273///
274/// Returns a comma-separated string of R formals, e.g., `"x, y = NULL, ..."`.
275pub(crate) fn build_r_formals_from_sig(
276    sig: &syn::Signature,
277    defaults: &std::collections::HashMap<String, String>,
278) -> String {
279    let mut builder = RArgumentBuilder::new(&sig.inputs);
280    if matches!(sig.inputs.first(), Some(syn::FnArg::Receiver(_))) {
281        builder = builder.skip_first();
282    }
283    builder = builder.with_defaults(defaults.clone());
284    builder.build_formals()
285}
286
287/// Build R `.Call()` arguments from a Rust function signature.
288///
289/// Automatically skips `self`/`&self` receivers (those are passed separately
290/// via [`DotCallBuilder::with_self`]). Dots become `list(...)`.
291///
292/// Returns a comma-separated string of R call arguments, e.g., `"x, y, list(...)"`.
293pub(crate) fn build_r_call_args_from_sig(sig: &syn::Signature) -> String {
294    let mut builder = RArgumentBuilder::new(&sig.inputs);
295    if matches!(sig.inputs.first(), Some(syn::FnArg::Receiver(_))) {
296        builder = builder.skip_first();
297    }
298    builder.build_call_args()
299}
300
301// region: Missing<T> detection for automatic defaults
302
303/// Check if a type is `Missing<T>` by examining the last path segment.
304///
305/// `Missing<T>` is the miniextendr wrapper for R's "missing argument" concept,
306/// allowing Rust functions to accept optional arguments that R callers can omit.
307pub(crate) fn is_missing_type(ty: &syn::Type) -> bool {
308    match ty {
309        syn::Type::Path(tp) => tp
310            .path
311            .segments
312            .last()
313            .map(|s| s.ident == "Missing")
314            .unwrap_or(false),
315        _ => false,
316    }
317}
318
319// endregion
320
321// region: DotCallBuilder - .Call() invocation formatting
322
323/// Builder for formatting `.Call()` invocations in R wrapper code.
324///
325/// Handles the common pattern of `.Call(C_ident, .call = match.call(), args...)`.
326///
327/// # Example
328///
329/// ```ignore
330/// let call = DotCallBuilder::new("C_Counter__increment")
331///     .with_self("self")
332///     .build();
333/// // => ".Call(C_Counter__increment, .call = match.call(), self)"
334///
335/// let call = DotCallBuilder::new("C_Counter__add")
336///     .with_self("x")
337///     .with_args(&["n"])
338///     .build();
339/// // => ".Call(C_Counter__add, .call = match.call(), x, n)"
340/// ```
341pub struct DotCallBuilder {
342    /// The C entry point symbol name (e.g., `"C_Counter__increment"`).
343    /// This is the first argument to `.Call()`.
344    c_ident: String,
345    /// Optional self/receiver variable name (e.g., `"self"`, `"x"`).
346    /// When present, prepended before other arguments in the `.Call()` invocation.
347    self_var: Option<String>,
348    /// Additional argument names passed after self (if any) in the `.Call()` invocation.
349    args: Vec<String>,
350    /// Expression for the `.call` named argument. `None` means `match.call()` (the default).
351    /// Set via [`DotCallBuilder::null_call_attribution`] to emit `.call = NULL` instead.
352    call_expr: Option<String>,
353}
354
355impl DotCallBuilder {
356    /// Create a new builder with the C function identifier.
357    pub fn new(c_ident: impl Into<String>) -> Self {
358        Self {
359            c_ident: c_ident.into(),
360            self_var: None,
361            args: Vec::new(),
362            call_expr: None,
363        }
364    }
365
366    /// Add a self/x parameter (prepended to args).
367    pub fn with_self(mut self, var: impl Into<String>) -> Self {
368        self.self_var = Some(var.into());
369        self
370    }
371
372    /// Add arguments after self (if any).
373    pub fn with_args(mut self, args: &[impl AsRef<str>]) -> Self {
374        self.args = args.iter().map(|s| s.as_ref().to_string()).collect();
375        self
376    }
377
378    /// Add a pre-joined argument string (e.g., `"x, y"`) as a single emit unit.
379    ///
380    /// Empty strings are ignored, so callers can pass the result of
381    /// `build_r_call_args_from_sig` directly without a length check.
382    pub fn with_args_str(mut self, args: &str) -> Self {
383        if !args.is_empty() {
384            self.args.push(args.to_string());
385        }
386        self
387    }
388
389    /// Pass `.call = NULL` instead of `.call = match.call()`.
390    ///
391    /// Use for lambda dispatch sites (R6 finalizer/`deep_clone`, S7 property
392    /// getter/setter/validator) where `match.call()` captures an internal
393    /// dispatch frame instead of the user's call. With `NULL`, the
394    /// `if (is.null(.val$call)) .call_default else .val$call` fallback in `condition_check_lines` surfaces the
395    /// nearest meaningful frame instead.
396    pub fn null_call_attribution(mut self) -> Self {
397        self.call_expr = Some("NULL".to_string());
398        self
399    }
400
401    /// Build the `.Call()` string.
402    pub fn build(&self) -> String {
403        let call_arg = self.call_expr.as_deref().unwrap_or("match.call()");
404
405        let mut all_args = Vec::new();
406
407        if let Some(ref self_var) = self.self_var {
408            all_args.push(self_var.clone());
409        }
410        all_args.extend(self.args.clone());
411
412        if all_args.is_empty() {
413            format!(".Call({}, .call = {})", self.c_ident, call_arg)
414        } else {
415            format!(
416                ".Call({}, .call = {}, {})",
417                self.c_ident,
418                call_arg,
419                all_args.join(", ")
420            )
421        }
422    }
423}
424// endregion
425
426// region: RoxygenBuilder - roxygen2 documentation tag generation
427
428/// Builder for generating roxygen2 documentation tags.
429///
430/// Provides a fluent API for building common roxygen tag patterns used
431/// across all class systems.
432///
433/// # Example
434///
435/// ```ignore
436/// let tags = RoxygenBuilder::new()
437///     .name("Counter$increment")
438///     .rdname("Counter")
439///     .export()
440///     .build();
441/// // => vec!["#' @name Counter$increment", "#' @rdname Counter", "#' @export"]
442/// ```
443pub struct RoxygenBuilder {
444    /// Value for `@name` tag. Identifies the documented topic (e.g., `"Counter$increment"`).
445    name: Option<String>,
446    /// Value for `@rdname` tag. Groups multiple entries onto a single help page
447    /// (e.g., all methods of `"Counter"` share one Rd file).
448    rdname: Option<String>,
449    /// Value for `@title` tag. The one-line title shown in help page headers.
450    title: Option<String>,
451    /// Value for `@description` tag. Longer description text below the title.
452    description: Option<String>,
453    /// Value for `@source` tag. Typically `"Generated by miniextendr"` provenance info.
454    source: Option<String>,
455    /// Whether to emit `@export`. When true, the item is exported from the package NAMESPACE.
456    export: bool,
457    /// Value for `@exportMethod` tag. Used for S4 method exports (e.g., `"show"`).
458    export_method: Option<String>,
459    /// Values for `@method` tag as `(generic, class)`. Used for S3 method dispatch
460    /// (e.g., `("print", "Counter")` emits `@method print Counter`).
461    method: Option<(String, String)>,
462    /// Additional custom tag lines emitted verbatim (without the `#' ` prefix,
463    /// which is added during [`build`](Self::build)). Used for tags like
464    /// `@keywords internal` or `@param` entries.
465    custom_tags: Vec<String>,
466}
467
468impl RoxygenBuilder {
469    /// Create a new empty builder.
470    pub fn new() -> Self {
471        Self {
472            name: None,
473            rdname: None,
474            title: None,
475            description: None,
476            source: None,
477            export: false,
478            export_method: None,
479            method: None,
480            custom_tags: Vec::new(),
481        }
482    }
483
484    /// Set the `@name` tag.
485    pub fn name(mut self, name: impl Into<String>) -> Self {
486        self.name = Some(name.into());
487        self
488    }
489
490    /// Set the `@rdname` tag (groups docs into one page).
491    pub fn rdname(mut self, rdname: impl Into<String>) -> Self {
492        self.rdname = Some(rdname.into());
493        self
494    }
495
496    /// Set the `@title` tag.
497    pub fn title(mut self, title: impl Into<String>) -> Self {
498        self.title = Some(title.into());
499        self
500    }
501
502    /// Set the `@description` tag.
503    #[allow(dead_code)] // Exercised by tests
504    pub fn description(mut self, desc: impl Into<String>) -> Self {
505        self.description = Some(desc.into());
506        self
507    }
508
509    /// Set the `@source` tag (typically "Generated by miniextendr...").
510    pub fn source(mut self, source: impl Into<String>) -> Self {
511        self.source = Some(source.into());
512        self
513    }
514
515    /// Add `@export` tag.
516    pub fn export(mut self) -> Self {
517        self.export = true;
518        self
519    }
520
521    /// Add `@exportMethod` tag (for S4).
522    #[allow(dead_code)] // Exercised by tests
523    pub fn export_method(mut self, method: impl Into<String>) -> Self {
524        self.export_method = Some(method.into());
525        self
526    }
527
528    /// Add `@method` tag (for S3).
529    pub fn method(mut self, generic: impl Into<String>, class: impl Into<String>) -> Self {
530        self.method = Some((generic.into(), class.into()));
531        self
532    }
533
534    /// Add a custom tag line (without the `#' ` prefix).
535    pub fn custom(mut self, tag: impl Into<String>) -> Self {
536        self.custom_tags.push(tag.into());
537        self
538    }
539
540    /// Build the roxygen tag lines (each prefixed with `#' `).
541    pub fn build(&self) -> Vec<String> {
542        let mut lines = Vec::new();
543
544        if let Some(ref title) = self.title {
545            lines.push(format!("#' @title {}", title));
546        }
547        if let Some(ref desc) = self.description {
548            lines.push(format!("#' @description {}", desc));
549        }
550        if let Some(ref name) = self.name {
551            lines.push(format!("#' @name {}", name));
552        }
553        if let Some(ref rdname) = self.rdname {
554            lines.push(format!("#' @rdname {}", rdname));
555        }
556        if let Some(ref source) = self.source {
557            lines.push(format!("#' @source {}", source));
558        }
559        if let Some((ref generic, ref class)) = self.method {
560            lines.push(format!("#' @method {} {}", generic, class));
561        }
562        for tag in &self.custom_tags {
563            lines.push(format!("#' {}", tag));
564        }
565        if self.export {
566            lines.push("#' @export".to_string());
567        }
568        if let Some(ref method) = self.export_method {
569            lines.push(format!("#' @exportMethod {}", method));
570        }
571
572        lines
573    }
574}
575
576/// Creates an empty builder with no tags set.
577impl Default for RoxygenBuilder {
578    fn default() -> Self {
579        Self::new()
580    }
581}
582// endregion
583
584// region: Tests
585
586#[cfg(test)]
587mod tests;
588// endregion