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