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