Dots and typed_list! Validation
This document describes miniextendr's support for R's ... (dots) arguments and the typed_list! macro for structured validation.
This document describes miniextendr’s support for R’s ... (dots) arguments and the typed_list! macro for structured validation.
🔗Overview
When an R function accepts ..., miniextendr converts it to a &Dots parameter. The typed_list! macro provides compile-time specification of expected list structure, with runtime validation.
🔗Basic Dots Usage
When you use name: ... in a function signature, miniextendr replaces it with a name: &Dots parameter (see Named Dots):
#[miniextendr]
pub fn count_args(args: ...) -> i32 {
// `args: &Dots` is created from `args: ...`.
// Access the underlying list:
let list = args.as_list();
list.len() as i32
}
Write _: ... (or _args: ...) when the function accepts dots it ignores:
#[miniextendr]
pub fn first_only(x: i32, _: ...) -> i32 {
x
}
_: ... binds the synthetic name __miniextendr_dots, which is not meant for your code; name the dots when the body reads them. The R formal is plain ... either way.
Don’t write a bare ... with no pattern. rustc rejects it before #[miniextendr] runs (the varargs_without_pattern lint, deny by default, rust-lang/rust#145544) and suggests _: ....
🔗Dots Methods
as_list(&self) -> List- Fast unchecked conversion to Listtry_list(&self) -> Result<List, SexpTypeError>- Type-checked conversion (a list; a pairlist is refused, asListrefuses one). Names are not checked:f(a = 1, a = 2)reaches the body, andList::first_duplicate_namefinds the repeattyped(&self, spec: TypedListSpec) -> Result<TypedList, TypedListError>- Validate against a spec
🔗Named Dots
You give dots a name with the name: ... syntax, which becomes a name: &Dots parameter:
#[miniextendr]
pub fn my_func(args: ...) -> i32 {
args.as_list().len() as i32
}
Writing the parameter yourself, args: &Dots, is the same thing. The name stays on the Rust side: the R formal is always plain ....
🔗Formals after ...
The parameter of type &Dots is R’s ... at its own position, so formals can follow it. Rust’s ... syntax only parses as the last parameter; spell a formal after the dots with an explicit &Dots parameter:
/// @param x A number.
/// @param ... Values to write.
/// @param overwrite Whether to overwrite.
#[miniextendr]
pub fn write_all(
x: i32,
rest: &Dots,
#[miniextendr(default = "FALSE")] overwrite: bool,
) -> String {
format!("x = {x}, {} values, overwrite = {overwrite}", rest.len())
}write_all <- function(x, ..., overwrite = FALSE) {
# ...
.Call(C_pkg_write_all, .call = sys.call(), x, list(...), overwrite)
}
The R formals and the .Call() arguments follow the Rust order. R matches a formal after ... by its exact name only:
write_all(1L, 2, 3, overwrite = TRUE) # the dots hold 2 and 3
write_all(1L, over = TRUE) # `over` is a dot; overwrite stays FALSE
write_all(1L, TRUE) # a positional extra is a dot too
The same holds for methods in every class system: fn collect(&self, n: i32, rest: &Dots, flag: bool) has the R formals n, ..., flag (after the receiver for S3, S4 and S7), with no second dispatch ....
- A function takes at most one
.... A second&Dotsparameter, Rust...next to an explicit&Dots, or a&Dotsnext to aLazyDots, is a compile error. - Rust
...with another parameter after it is a compile error that names the&Dotsspelling, andminiextendr-lint’s “failed to parse” warning for that file ends with the same hint. - The dots parameter takes no default,
match_argor check;Missing<&Dots>is refused. The dots are always present. - On a
call = callerorcall_argwrapper,.callgoes last, after the dots and after any formal that follows them:function(x, ..., overwrite = FALSE, .call = NULL). #[miniextendr(dots = typed_list!(...))]reads an explicit&Dotsthe same way it reads....- A function with dots runs on the R main thread, even under
worker: the dots are an R list.
🔗Dots left unforced: LazyDots
A &Dots parameter receives list(...), which R evaluates before the Rust body runs: every element is forced, and an empty argument (f(a = ), the empty positions of x[1, , , ]) stops the call with argument is missing, with no default. A parameter of type LazyDots is R’s ... at its position too, but the wrapper passes its own frame, environment(), and evaluates nothing on the way in (#1892). The body counts and names the dots, sees which are empty, reads what was written, and forces the elements it wants, each in its own environment.
use miniextendr_api::{LazyDots, Missing, Quoted, SEXP, miniextendr};
/// @export
#[miniextendr(s3(generic = "subset", class = "mx_lazy"))]
pub fn mx_lazy_subset(
x: SEXP,
subset: Missing<Quoted>,
rest: LazyDots,
) -> Result<SEXP, LazyUnsupportedArgs> {
if !rest.is_empty() {
// Names the extra arguments; none of them is evaluated.
return Err(unsupported("subset", &rest));
}
// ... keep the rows where `subset` is TRUE
}subset.mx_lazy <- function(x, subset, ...) {
.Call(C_pkg_mx_lazy_subset, .call = environment(), x,
if (missing(subset)) quote(expr=) else list(substitute(subset), parent.frame()),
environment())
# ...
}
subset(x, TRUE, select = ID) then raises the method’s own mx_lazy_unsupported_args error naming select, where list(...) would have stopped on object 'ID' not found.
| Method | R equivalent | Forces |
|---|---|---|
len() / is_empty() | ...length() | nothing |
names() -> Vec<Option<&str>> | ...names(); None for an unnamed element | nothing |
is_missing_arg(i) -> bool | element i of substitute(list(...)) is the empty argument | nothing |
expr(i) -> SEXP | element i of substitute(list(...)) | nothing |
force(i) -> SEXP | ..k, with k = i + 1 | element i |
try_force(i) -> Result<SEXP, REvalError> | ..k, an R error returned as Err | element i |
forward(call) -> SEXP | f(<args>, ...), evaluated in the frame | what f forces |
try_forward(call) -> Result<SEXP, REvalError> | the same, an R error returned as Err | what f forces |
next_method() -> SEXP | NextMethod() | what the next method forces |
- Indices are 0-based, as in
List::get_index: elementiis R’s..kwithk = i + 1. An index past the end panics before anything is forced, as slice indexing does. - Forwarded dots. Each element keeps the environment it was written in, also when another function passed its own dots on (
g <- function(...) f(x, ...)):force(i)forces the element’s promise there, andexpr(i)gives what the caller ofgwrote. - Empty and missing elements.
is_missing_arg(i)istrueonly for a literally empty slot, the element R stores as the missing argument:f(a = ),update(x, select = ), the empty positions ofx[1, , , ]. That is what R 4.6’sR_GetDotType()reports as missing. An element forwarded from a missing argument is not empty: ing <- function(a) f(1, a)called asg(), element 1 is the promise ofa, sois_missing_arg(1)isfalse, and forcing it raises R’s ownargument "a" is missing, with no default, aslist(...)would. - Forcing an empty element raises
argument "..k" is missing, with no default(classesevalError,missingArgErrorfrom R 4.6,simpleErrorbefore), with the generated function’s own call as its call. A body that accepts empty arguments checksis_missing_arg(i)first. - Conditions.
force(i)evaluates througheval_with_handlers(see QUOTED_ARGUMENTS.md): the caller’swithCallingHandlers()/suppressWarnings()see what forcing signals, and an error, or the exit of atryCatch()handler, unwinds through the Rust frames (running their destructors) and carries on in R as the original condition.try_force(i)evaluates throughtry_eval_with_handlers: warnings and messages still reach the caller’s handlers, any other exit unwinds as underforce(i), and an R error comes back asErr(REvalError)for the body to handle (an empty element included). - Caching. Forced values are not cached in Rust: R stores a promise’s value in the promise, so forcing an element twice evaluates it once. The count and the expressions are read once per
LazyDotsvalue: the firstis_missing_arg/exprcall evaluatessubstitute(list(...))in the frame and the value keeps the list, so a loop over the elements copies the expressions once. - Passing the dots on.
forward(call)evaluates anRCallin the wrapper’s frame with...added last, or whereRCall::dots()put it (RCall::new("f").dots().named_value("na.rm", true)isf(..., na.rm = TRUE)). R hands the callee each element as the promise it matched: unevaluated, with the environment it was written in. A data-masking function such asdplyr::group_by()then evaluates the expressions against its own data, as when R code writesf(x, ...). The function is looked up from the frame (the package namespace, its imports, base, the search path); name one from a suggested package withRCall::namespaced("dplyr", "group_by"). Arguments added witharg_value/named_valueare values; a symbol or call added withargis evaluated in the frame, where the wrapper’s formals are bound, so pass a language object withquoted_arg. Conditions behave as underforce(i)/try_force(i). - The next S3 method.
next_method()evaluatesNextMethod()in the frame, which is the S3 method’s own: R finds the dispatch it is in and calls the next method with the method’s arguments as R matched them, the dots passed on unevaluated. Only ans3(...)function or an S3 / vctrs impl-block method has a dispatch to continue; anywhere else R raises its ownNextMethoderror.
use miniextendr_api::{LazyDots, SEXP, expression::RCall, miniextendr};
/// `group_by()` on the plain data frame inside the object.
/// @export
#[miniextendr(s3(generic = "group_by", class = "my_results"))]
pub fn my_results_group_by(data: SEXP, rest: LazyDots) -> Result<SEXP, String> {
let plain = plain_frame(data); // the data frame inside the object
let group_by = unsafe { RCall::namespaced("dplyr", "group_by") }
.map_err(|e| e.to_string())?;
Ok(rest.forward(group_by.arg(plain)))
}
group_by(results, ID, ANALYTE) reaches dplyr::group_by(plain, ID, ANALYTE) with ID and ANALYTE unevaluated, so dplyr resolves them as columns of plain.
- R versions. Every method runs a base R call in the frame (
...length(),...names(),substitute(list(...)),..k) througheval_with_handlers, so the same code runs on R 4.4 and later. It uses neither R 4.6’s dots C API nor the non-API promise accessors; #1898 tracks moving to that API, and a per-elementenv(i), once a build can target R 4.6.
Where it is accepted:
- Standalone functions,
s3(...)functions, and the impl-block methods of every class system: env, R6, S3, S4 (also through S4’s.localrewrite), S7 (dispatch and the<Class>_<method>shortcut) and vctrs (static methods and protocol methods such asformat()). The R formals are the same as for&Dots; the.Call()passesenvironment()at the position of.... - The parameter is taken by value with the whole type
LazyDots(optionallyLazyDots<'_>or a path ending in it).&LazyDots,Option<LazyDots>andMissing<LazyDots>are compile errors: the dots are always present (len()is 0 when the call passes none). - It takes no per-parameter option (
default,coerce,match_arg,no_na, …) and nodots = typed_list!(...), which validates a forced list. - Trait methods and
extern "C-unwind"functions refuse it: a trait method’s arguments cross the trait ABI as converted values, and.Call()reaches anexternfunction without a generated wrapper. - A function with a
LazyDotsparameter runs on the R main thread, even underworker, and the value is!Send. - A function without one keeps its wrapper byte for byte.
rpkg/src/rust/lazy_dots_tests.rs has the fixtures (the readers and forcers, subset.mx_lazy, update.mx_lazy, `[.mx_lazy_grid`, the forward / next_method fixtures and one class per class system), and rpkg/tests/testthat/test-lazy-dots.R tests them.
🔗Refusing unused arguments: the dots role
An S3 method takes ... because its generic does, and R puts every argument the method has no formal for into it. A method that reads none of them loses a misspelt argument without a word: confint(fit, levle = 0.9) returns the 95% interval, and predict(fit, nwedata = nd) predicts at the observed points. #[miniextendr(dots = ...)] says which arguments ... takes. The generated wrapper refuses the others before it evaluates anything, all in one error, each with the name it most likely meant:
dots = | ... takes | The Rust function |
|---|---|---|
empty | nothing | reads no dots |
empty(allow = ["row.names", "optional"]) | the listed names | ignores them |
forward(allow = ["digits"]) | the listed names | passes them on to another R function |
data | anything (no check) | reads them |
An argument the function reads is a parameter, not a name in ...: R then matches it by name or by position, as for any function. A dotted R name such as row.names is a parameter that declares its R name with formal (MINIEXTENDR_ATTRIBUTE.md): #[miniextendr(formal = "row.names", default = "NULL")] row_names: Option<String> on a function, formal(row_names = "row.names") on an impl method. dots = data is for names the function can’t know in advance.
/// @param x A `fit`.
/// @param digits Significant digits.
/// @param ... Must be empty.
/// @export
#[miniextendr(s3(generic = "format", class = "fit"), dots = empty)]
pub fn format_fit(x: List, #[miniextendr(default = "3L")] digits: i32, _dots: ...) -> String {
/* ... */
}
/// `data.frame()` calls `as.data.frame(x, optional = TRUE, stringsAsFactors = ...)`.
/// @param x A `fit`.
/// @param ... `row.names`, `optional` and `stringsAsFactors`, ignored.
/// @export
#[miniextendr(
s3(generic = "as.data.frame", class = "fit"),
dots = empty(allow = ["row.names", "optional", "stringsAsFactors"])
)]
pub fn as_data_frame_fit(x: List, _dots: ...) -> BuiltDataFrame { /* ... */ }format(fit, digts = 2)
#> Error in format(fit, digts = 2) :
#> unused argument 'digts' (did you mean 'digits'?)
format(fit, digts = 2, zzz = 1)
#> Error in format(fit, digts = 2, zzz = 1) :
#> unused arguments 'digts' (did you mean 'digits'?), 'zzz'
- Where the check runs. First in the wrapper body, after the lifecycle notice and before the argument checks, so a misspelt argument is reported before the missing one it was meant to be. It reads
...length()and...names()only, so no argument is forced:format(fit, digts = stop("boom"))reportsdigts. When...is empty the cost is one...length()call. - Names. Every name not in
allowis refused. Partial matching does not apply:opt = TRUEis notoptional. - Unnamed arguments. A method whose only formal is
xreceives the generic’s later arguments in..., unnamed, when the caller passes them by position.-
Under
forwardthey pass, as they are. The function the dots go on to matches them by position itself. -
Under
emptynothing reads them, so only their number counts. Up to one unnamed argument is taken for each allowed name not given by name, and the rest are refused. With theas.data.framemethod above:as.data.frame(fit, NULL, TRUE) # row.names and optional, by position as.data.frame(fit, NULL, TRUE, FALSE, 1) #> Error in as.data.frame(fit, NULL, TRUE, FALSE, 1) : #> unused argument '..4' (unnamed) -
emptywith noallowtakes none.
-
- The suggestion. It is taken from the wrapper’s formals and the allowed names. It is the one name the argument is a prefix of. Otherwise it is the first closest name by edit distance, where swapping two neighbouring characters counts as one edit. The distance must be at most a third of the argument’s length, but at least one edit, and fewer edits than the argument has characters. If no name qualifies, there is no suggestion.
- The condition is an argument error (CONDITIONS.md), carrying:
- Classes:
miniextendr_error_unused_argument, then the crate’sconversion_error_class,rust_errorand the base classes.class = "pkg_unused_argument"(orclass = ["a", "b"]) inempty(..)/forward(..)puts the package’s own classes in front. - Fields:
kind = "conversion";param = "...";argument: each refused argument,..<i>for an unnamed one at positioniof...;suggestion: one entry per argument,NAwhere there is none.
- Call: the wrapper’s call, the generic’s for an S3 method,
.mx_callundercall = caller, or none undercall = none.
- Classes:
- Which
....- A standalone function’s
...is its own dots parameter (_: ...,&DotsorLazyDots), so every role needs one. An S3 method takes one anyway. - An impl-block method of an S3, S4 or S7 class gets the generic’s dispatch
...without one, andemptychecks that...(unlesss3(no_dots)/s7(no_dots)removes it). So does a vctrs protocol method. - R6 and env methods, and static methods, have only the formals of the Rust signature.
forwardanddatahand the arguments to the function, so they need its dots parameter.
- A standalone function’s
- Scope. The attribute is per function or method; there is no crate-level default. The
[[,as.list()and[[<-field methods thats3(r_data_accessors)generates make the same check on a bare pointer, always, with no attribute, and so does an env class’s[[;[[takesexactas a formal (see CLASS_SYSTEMS.md). Trait-ABI methods don’t take it yet (#2175).
rpkg/src/rust/dots_policy_tests.rs has the fixtures (standalone S3 methods, a plain function with a required argument, forward with a package class, an impl-block S3 method and an R6 method), and rpkg/tests/testthat/test-dots-policy.R tests them.
🔗typed_list! Macro
The typed_list! macro creates a TypedListSpec for validating list structure.
🔗Basic Syntax
typed_list!(
field_name => type_spec,
another_field => type_spec,
optional_field? => type_spec, // ? marks optional
)🔗Type Specifications
| Syntax | Description |
|---|---|
numeric() | Real/double vector, any length |
numeric(4) | Real/double vector, exactly 4 elements |
integer() | Integer vector |
logical() | Logical vector |
character() | Character vector |
raw() | Raw vector |
complex() | Complex vector |
list() | List (VECSXP) or NULL; a pairlist is refused, as List refuses one |
"data.frame" | Object inheriting from class |
"my_class" | Any class name as string literal |
🔗Strict Mode
By default, extra fields are allowed. Use @exact; for strict validation:
typed_list!(@exact;
x => numeric(),
y => numeric()
)
// Extra fields like `z` will cause an error🔗Manual Validation
Call .typed() explicitly in your function body:
use miniextendr_api::typed_list;
#[miniextendr]
pub fn validate_args(dots: ...) -> Result<String, String> {
let args = dots.typed(typed_list!(
alpha => numeric(4),
beta => list(),
gamma? => character() // optional
)).map_err(|e| e.to_string())?;
let alpha: Vec<f64> = args.get("alpha").map_err(|e| e.to_string())?;
let gamma: Option<String> = args.get_opt("gamma").map_err(|e| e.to_string())?;
Ok(format!("alpha has {} elements", alpha.len()))
}🔗Attribute Sugar (Recommended)
Use #[miniextendr(dots = typed_list!(...))] for automatic validation:
#[miniextendr(dots = typed_list!(x => numeric(), y => numeric()))]
pub fn my_func(_: ...) -> String {
// `dots_typed` is automatically created and validated
let x: f64 = dots_typed.get("x").expect("x");
let y: f64 = dots_typed.get("y").expect("y");
format!("x={}, y={}", x, y)
}
This injects validation at the start of the function body, over the dots binding (__miniextendr_dots for _: ..., otherwise the name of the name: ... or name: &Dots parameter, at any position):
let dots_typed = __miniextendr_dots
.typed(typed_list!(...))
.unwrap_or_else(|e| miniextendr_api::arg_error!(
param = "...", "dots validation failed: {e}"
));
Validation failures use the same argument-error condition as wrapper checks:
kind = "conversion", param = "...", and the package’s configured
conversion_error_class ahead of rust_error. The message keeps the
typed-list error’s human-readable text.
🔗With Optional Fields
#[miniextendr(dots = typed_list!(
name => character(),
greeting? => character()
))]
pub fn greet(_: ...) -> String {
let name: String = dots_typed.get("name").expect("name");
let greeting: Option<String> = dots_typed.get_opt("greeting").expect("greeting");
let greeting = greeting.unwrap_or_else(|| "Hello".to_string());
format!("{}, {}!", greeting, name)
}🔗TypedList Methods
After validation, TypedList provides typed accessors:
get<T>(&self, name: &str) -> Result<T, TypedListError>- Get required fieldget_opt<T>(&self, name: &str) -> Result<Option<T>, TypedListError>- Get optional fieldget_raw(&self, name: &str) -> Result<SEXP, TypedListError>- Get raw SEXPas_list(&self) -> List- Get underlying List
get::<T>() and get_opt::<T>() accept both scalar and vector/collection
target types. T can be a scalar (f64, i32, String, bool, …) or a
vector (Vec<f64>, Vec<i32>, Vec<String>, Vec<Option<T>>, …) — anything
implementing TryFromSexp with a Display error type:
let survive: Vec<i32> = args.get("survive")?; // integer() field -> Vec<i32>
let weights: Vec<f64> = args.get("weights")?; // numeric() field -> Vec<f64>
let labels: Option<Vec<String>> = args.get_opt("labels")?; // optional character()🔗Error Types
TypedListError variants:
| Variant | Description |
|---|---|
NotList | Input was not a list |
Missing { name } | Required field is missing |
WrongType { name, expected, actual } | Field has wrong type |
WrongLen { name, expected, actual } | Field has wrong length |
ExtraFields { names } | Extra fields in strict mode |
DuplicateNames { name } | Duplicate field names |
🔗R Usage
From R, call functions with named arguments:
# Valid
validate_args(alpha = c(1.0, 2.0, 3.0, 4.0), beta = list(1, 2))
# Missing required field -> error
validate_args(beta = list(1, 2))
# Error: missing required field: "alpha"
# Wrong type -> error
validate_args(alpha = 1:4, beta = list()) # integer instead of numeric
# Error: field "alpha" has wrong type: expected numeric, got integer
# Wrong length -> error
validate_args(alpha = c(1.0, 2.0), beta = list()) # 2 elements instead of 4
# Error: field "alpha" has wrong length: expected 4, got 2