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