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