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