How to map Rust enums to R factors and character strings.

miniextendr provides two complementary systems for enum-like types:

SystemR RepresentationPartial MatchDefaultUse Case
RFactorFactor (integer + levels)No-Categorical data for table(), lm(), etc.
MatchArgCharacter scalarYesFirst choiceParameter validation (match.arg() style)

Both systems pass the enum by value (as a factor/string). If instead you want the enum to be a methods-bearing instance — an ExternalPtr wrapped with a class-system impl block — see CLASS_SYSTEMS.md § Enums: value vs instance. Note the two are mutually exclusive: deriving ExternalPtr alongside MatchArg/RFactor is a compile error (E0119, conflicting IntoR).

🔗RFactor: enum as R Factor

Maps a Rust enum to an R factor with levels. Each variant becomes a level. The enum must be Copy (RFactor and its supertrait MatchArg require it).

#[derive(Copy, Clone, RFactor)]
pub enum Color {
    Red,    // level index 1
    Green,  // level index 2
    Blue,   // level index 3
}

Use in functions:

#[miniextendr]
pub fn describe(color: Color) -> &'static str {
    match color {
        Color::Red => "warm",
        Color::Green => "cool",
        Color::Blue => "cool",
    }
}

#[miniextendr]
pub fn favorite() -> Color {
    Color::Blue
}

From R:

describe(factor("Red", levels = c("Red", "Green", "Blue")))
# [1] "warm"

favorite()
# [1] Blue
# Levels: Red Green Blue

🔗Rename Variants

#[derive(Copy, Clone, RFactor)]
#[r_factor(rename_all = "snake_case")]
pub enum Status {
    InProgress,   // level: "in_progress"
    Completed,    // level: "completed"
    NotStarted,   // level: "not_started"
}

#[derive(Copy, Clone, RFactor)]
pub enum Priority {
    #[r_factor(rename = "lo")]
    Low,
    #[r_factor(rename = "med")]
    Medium,
    #[r_factor(rename = "hi")]
    High,
}

Supported rename_all values: snake_case, kebab-case, lower, upper.

🔗Factor Vectors

Use FactorVec<T> for vectors and FactorOptionVec<T> for vectors with NA:

use miniextendr_api::{FactorVec, FactorOptionVec};

#[miniextendr]
pub fn all_colors() -> FactorVec<Color> {
    FactorVec(vec![Color::Red, Color::Green, Color::Blue])
}

#[miniextendr]
pub fn parse_colors(input: FactorOptionVec<Color>) -> Vec<&'static str> {
    input.0.iter().map(|c| match c {
        Some(Color::Red) => "red",
        Some(Color::Green) => "green",
        Some(Color::Blue) => "blue",
        None => "NA",
    }).collect()
}

From R:

all_colors()
# [1] Red   Green Blue
# Levels: Red Green Blue

x <- factor(c("Red", NA, "Blue"), levels = c("Red", "Green", "Blue"))
parse_colors(x)
# [1] "red" "NA"  "blue"

🔗Caching

The #[derive(RFactor)] macro generates a OnceLock-cached levels STRSXP. The levels string vector is allocated once and reused for all subsequent conversions, giving ~4x speedup for single-value conversions.

🔗Via #[miniextendr]

Instead of #[derive(RFactor)], you can use the attribute macro:

#[miniextendr]
#[derive(Copy, Clone)]
pub enum Color { Red, Green, Blue }

These are equivalent. #[miniextendr] on a fieldless enum dispatches to the same RFactor derive internally.


🔗MatchArg: enum as string parameter

Maps a Rust enum to R character strings with match.arg() validation. Supports partial matching and defaults to the first variant when NULL is passed (an Option<T> parameter is the exception; see “Optional Choice” below). The enum must be Copy (MatchArg: Copy).

#[derive(Copy, Clone, MatchArg)]
pub enum Mode {
    Fast,    // choice: "Fast"
    Safe,    // choice: "Safe"
    Debug,   // choice: "Debug"
}

Use in functions:

#[miniextendr]
pub fn run(#[miniextendr(match_arg)] mode: Mode) -> String {
    match mode {
        Mode::Fast => "running fast".into(),
        Mode::Safe => "running safe".into(),
        Mode::Debug => "running debug".into(),
    }
}

The generated R wrapper shows the choice list directly as the formal default and validates through a helper defined once at the top of the wrappers file:

run <- function(mode = c("Fast", "Safe", "Debug")) {
  mode <- .miniextendr_match_arg(mode, c("Fast", "Safe", "Debug"), "mode")
  .Call(C_mypkg_run, mode)
}

.miniextendr_match_arg() follows base::match.arg(): an omitted argument (or NULL) selects the first choice, a single string is matched exactly or as a unique prefix, and a factor is read as its labels. Unlike match.arg() the message names the argument ('mode' should be one of "Fast", "Safe", "Debug") and the choice list is spelled out in the call rather than read off the formal (#1552).

The enum’s CHOICES are spliced in at wrapper-gen time (not stored in an R variable), so ?run and tab-completion both show the real options. If you set an explicit default = "\"Safe\"", the splice rotates that value to position 1 of the vector — match.arg(arg) returns arg[1] when arg matches the formal default, so the rotated value becomes the effective default while the rest of the choices remain visible: function(mode = c("Safe", "Fast", "Debug")). The default value must be one of the enum’s choices; otherwise wrapper generation panics at write time. When another parameter is named c, the formal default and the usage line read mode = base::c("Fast", "Safe", "Debug") (MINIEXTENDR_ATTRIBUTE.md).

From R:

run("Fast")       # exact match
run("F")          # partial match → "Fast"
run()             # NULL → default (first choice: "Fast")
run("Saf")        # partial match → "Safe"
run("X")          # Error: 'mode' should be one of "Fast", "Safe", "Debug"

A MatchArg enum parameter without #[miniextendr(match_arg)] gets no R-side match.arg() check. Its Rust conversion reads the value as the check does (NULL or the whole choice vector selects the first choice, a factor is read as its labels, a unique prefix matches) and refuses a value with the argument error the check raises, word for word: 'mode' should be one of "Fast", "Safe", "Debug", 'mode' must be of length 1, 'mode' must be NULL or a character vector (#1767). Only e$rust_type tells the two apart.

🔗Matching a Raw Argument in the Body

Some arguments are only a choice on some paths: an argument the body hands on to another function, or one whose meaning depends on another argument. Take it as SEXP and match it in the body with match_arg_param::<T>(value, "mode"). It follows the wrapper’s .miniextendr_match_arg() exactly, results and messages alike (#1741), and its error raises as the wrapper’s argument error (kind = "conversion", e$param, the crate’s conversion_error_class, the wrapper’s call):

use miniextendr_api::{MatchArg, SEXP, match_arg_param, miniextendr};

#[derive(Copy, Clone, MatchArg)]
#[match_arg(rename_all = "snake_case")]
pub enum Fill {
    Drop,
    Draw,
    Error,
}

#[miniextendr]
pub fn fill_with(fill: SEXP) -> String {
    let fill: Fill = match_arg_param(fill, "fill").unwrap_or_else(|e| e.raise());
    fill.to_choice().to_string()
}
fillResult
NULL, or the whole choice vector c("drop", "draw", "error")Drop (the first choice)
"error", "e" (a unique prefix), factor("draw")that choice
"dr" (an ambiguous prefix), "zzz", "", NA_character_'fill' should be one of "drop", "draw", "error"
c("drop", "draw"), character(0)'fill' must be of length 1
1L, TRUE, list("drop")'fill' must be NULL or a character vector

As in match.arg(), NA_character_ is matched as the string "NA", so it selects a choice spelled "NA" or with that prefix, and the whole choice vector selects the first choice only when it has no attributes (a named vector is a vector of length 3). The returned ArgError carries the parameter name and message (e.param(), e.message()) for a caller that words its own error; arg_error! raises any other argument error from a body (CONDITIONS.md).

match_arg_param matches against T::CHOICES in declaration order, the formal of a plain match_arg parameter. A parameter with default = "..." moves the default to the front of its formal and its check (c("Safe", "Fast", "Debug") for default = "\"Safe\""), which changes the first choice, the vector that counts as the whole choice vector, and the order of the choices in the message. A body that matches a value forwarded from such a formal passes the same default to match_arg_param_with_default (#1767):

let mode: Mode = match_arg_param_with_default(mode, "mode", Mode::Safe)
    .unwrap_or_else(|e| e.raise());

The default is a T, not a string, so it is always one of the choices. Both this function and the wrappers writer order the choices with the same code.

🔗Optional Choice: Option<T>

With a plain T, an omitted argument and an explicit NULL both resolve to the first choice, so “no choice was made” cannot be expressed. Declare the parameter as Option<T> for that (#1473):

#[miniextendr]
pub fn run(#[miniextendr(match_arg)] mode: Option<Mode>) -> String {
    match mode {
        Some(mode) => format!("running {mode:?}"),
        None => "engine decides".into(),
    }
}

The R formal defaults to NULL instead of the choice vector, the prelude skips the check for NULL, and the auto-generated @param line ends in “, or NULL for no choice”. NULL arrives as None; any other value is matched exactly as for the plain type:

run <- function(mode = NULL) {
  if (!is.null(mode)) mode <- .miniextendr_match_arg(mode, c("Fast", "Safe", "Debug"), "mode")
  .Call(C_mypkg_run, mode)
}

run()             # None
run(NULL)         # None
run("Sa")         # Some(Safe)
run("X")          # Error: 'mode' should be one of "Fast", "Safe", "Debug"

The same works for choices(...) on an Option<String> / Option<&str> parameter. A default = "..." on an Option<T> choice parameter is a compile error (the formal is NULL by definition; drop the Option to make a choice the default). The generated C wrapper decodes the argument with match_arg_option_from_sexp, so any MatchArg type works, derived or hand-written.

🔗Omitted Choice

Option<T> gives up the choice vector in the usage line: the formal has to be NULL. To keep mode = c("Fast", "Safe", "Debug") visible in ?run and still learn that the caller made no choice, wrap the type in Missing<..>. Omission and nullability stay separate contracts: Missing reports the first, Option the second, and neither turns into the other (#1551).

use miniextendr_api::Missing;

#[miniextendr]
pub fn run(#[miniextendr(match_arg)] mode: Missing<Option<Mode>>) -> String {
    match mode {
        Missing::Absent => "inherit the mode from elsewhere".into(),
        Missing::Present(None) => "explicitly no mode".into(),
        Missing::Present(Some(mode)) => format!("running {mode:?}"),
    }
}
run <- function(mode = c("Fast", "Safe", "Debug")) {
  if (!missing(mode) && !identical(mode, c("Fast", "Safe", "Debug")) && !is.null(mode)) mode <- .miniextendr_match_arg(mode, c("Fast", "Safe", "Debug"), "mode")
  .Call(C_mypkg_run, .call = sys.call(), if (missing(mode) || identical(mode, c("Fast", "Safe", "Debug"))) quote(expr=) else mode)
}

run()                               # Missing::Absent
run(c("Fast", "Safe", "Debug"))     # Missing::Absent, the formal passed whole
run(NULL)                           # Missing::Present(None)
run("Sa")                           # Missing::Present(Some(Safe))
run("X")                            # Error: 'mode' should be one of "Fast", "Safe", "Debug"

The wrapper skips the check for an omitted argument and forwards R’s missing-argument sentinel to Rust (the same quote(expr=) forwarding every Missing<T> parameter uses), so the formal default is only documentation. Passing that default explicitly means the same as leaving it out (#2152): a value identical() to the formal, such as the usage line pasted back, skips the check and reaches Rust as Absent too. identical() compares attributes, so a named or classed copy of the vector is not the formal, and the check reads it as any other value: a named copy is refused for its length, and a factor of the choices reads as its labels, the whole vector, so as the first choice. Explicit NULL is Present(None), exactly as for any Missing<Option<T>>; collapse the two with mode.into_option().flatten() when the difference does not matter. The auto-generated @param line ends in “, or NULL; omitting the argument or passing the whole list means no choice”.

Every form follows one rule. Without Missing, the whole choice vector reads the way match.arg() reads it, as the first choice. Missing<X> behaves like X for every supplied value, and reports Absent for an omitted argument and for its formal passed whole, since passing the formal is the same call as leaving it out. So in one package, c("Fast", "Safe", "Debug") reaches a Mode parameter as Fast and a Missing<Mode> parameter as Absent: the parameter’s type decides, not the call. A several_ok list is the exception: it reads its whole vector as every choice, omittable or not:

Parameter typeFormalOmittedChoice vectorNULL"Sa"
ModechoicesFast (first)Fast (first)FastSafe
Option<Mode>NULLNoneSome(Fast) (first)NoneSome(Safe)
Missing<Mode>choicesAbsentAbsentPresent(Fast)Present(Safe)
Missing<Option<Mode>>choicesAbsentAbsentPresent(None)Present(Some(Safe))
Missing<Vec<Mode>> (several_ok)choicesAbsentPresent (all)Present (all)Present(vec![Safe])
Either<Mode, R>choicesLeft(Fast) (first)Left(Fast) (first)Right(..) (converted to R; a DataFrame refuses it)Left(Safe)
Missing<Either<Mode, R>>choicesAbsentAbsentPresent(Right(..)) (converted to R)Present(Left(Safe))
Missing<Either<Vec<Mode>, R>> (several_ok)choicesAbsentPresent(Left(..)) (all)Present(Right(..)) (converted to R)Present(Left(vec![Safe]))

A choice type with a single variant has the vector c("Only") as its formal, so under Missing<..> every value that matches it, "Only" or a prefix such as "On", reaches Rust as Absent (#2182).

To refuse an explicit NULL in any of these forms, with your own message, see Refusing an Explicit NULL.

choices(...) accepts the same wrappers around String / &str. Missing<..> has to be the outermost wrapper (Option<Missing<T>> is a compile error), and it cannot carry a default = "..." (nor defaults(p = "...") on a method), a compile error. Other Missing<T> parameters take one (#2161), but here it would mean nothing: an omitted argument reaches Rust as Absent, so a default picks no choice, and the formal already shows the choices, which the identical() check above compares with. Drop Missing to make a choice the default (default = "\"Safe\"" on Mode), or drop the default. Impl methods of every class system take the same types through the method-level match_arg(p) / match_arg_several_ok(p) / choices(p = "...") attributes; trait methods accept choices(p = "...") / choices_several_ok(p = "...") only, so they take the string forms (Missing<String>, Missing<Option<String>>, Missing<Vec<String>>, and with the either feature Missing<Either<String, R>> / Missing<Either<Vec<String>, R>>). An omitted argument crosses the trait ABI of a #[miniextendr] trait as R’s missing-argument sentinel, so a cross-package call sees Absent too.

A hand-written R function in front of such a wrapper passes omission on if its own formal has no default: run <- function(mode) run_impl(mode) reaches Rust as Absent when called as run(). With a default, run() hands that default over as a supplied value (R’s missing() does not see through a formal with a default). It still reaches Rust as Absent when it is the wrapper’s own formal, the same choices in the same order (function(mode = c("Fast", "Safe", "Debug")) run_impl(mode)), and any other default (c("Fast", "Safe")) is matched as a value. To give the generated wrapper itself a bare formal, use no_default (below).

🔗Without a Default: no_default

The choice vector in the formal tells an R user that omitting the argument picks the first choice (every choice under several_ok). A function that refuses an omitted argument has no such default. Add no_default and the formal is the bare name (#1828):

use miniextendr_api::{arg_error, miniextendr, Missing};

#[miniextendr]
pub fn run(#[miniextendr(match_arg, no_default)] mode: Missing<Mode>) -> String {
    match mode {
        Missing::Absent => arg_error!(param = "mode", "'mode' is required: there is no mode to assume"),
        Missing::Present(mode) => format!("running {mode:?}"),
    }
}
run <- function(mode) {
  if (!missing(mode)) mode <- .miniextendr_match_arg(mode, c("Fast", "Safe", "Debug"), "mode")
  .Call(C_mypkg_run, .call = sys.call(), if (missing(mode)) quote(expr=) else mode)
}

run()             # Error: 'mode' is required: there is no mode to assume
run("Sa")         # Missing::Present(Safe)
run("X")          # Error: 'mode' should be one of "Fast", "Safe", "Debug"

Only the formal changes, and with it the usage line (run(mode)) and args(run). The prelude, the choice list, partial matching and factor input stay as they were, since the helpers take the choice list as an argument. With no vector in the formal, the whole choice vector passed explicitly is matched like any value (run(c("Fast", "Safe", "Debug")) is Present(Fast), as for a plain Mode). An omitted Missing<..> argument still reaches Rust as Absent, so the function words its own refusal (here with arg_error!, the argument error the wrapper’s own checks raise; see CONDITIONS.md). Any other type (Mode, Option<Mode>, an Either, a several_ok list) behaves like any parameter without a default: the prelude’s first use of the omitted argument raises R’s argument "mode" is missing, with no default.

The auto-generated @param line still lists the choices and the other accepted values (, or NULL, , or a data frame), but it drops “omitting the argument means no choice”: what omission means is yours to document. The keyword works with choices(...) and several_ok as well (#[miniextendr(match_arg, several_ok, no_default)]). It is a compile error together with default = "..." and on a parameter that is neither match_arg nor choices. Impl and trait methods name the parameter at method level, no_default(p) (or no_default(p, q)), next to match_arg(p) / choices(p = "...").

🔗Choice or Another Value: Either<T, R>

With the either feature, a choice parameter can also take a value of a different kind: a named route or a data frame of doses, a level name or a number. Declare it as Either<T, R> with the choice type on the left:

use miniextendr_api::either_impl::Either;
use miniextendr_api::DataFrame;

#[derive(Copy, Clone, Debug, MatchArg)]
#[match_arg(rename_all = "lower")]
pub enum Route { Oral, Bolus, Infusion }

#[miniextendr]
pub fn set_route(#[miniextendr(match_arg)] route: Either<Route, DataFrame>) -> String {
    match route {
        Either::Left(route) => format!("{route:?}"),
        Either::Right(doses) => format!("{} dose rows", doses.nrow()),
    }
}

The formal default is T’s choice vector, and the prelude matches the argument only when it is a form match.arg() reads (character or factor); anything else reaches Rust unchanged:

set_route <- function(route = c("oral", "bolus", "infusion")) {
  if (is.character(route) || is.factor(route)) route <- .miniextendr_match_arg(route, c("oral", "bolus", "infusion"), "route")
  .Call(C_mypkg_set_route, .call = sys.call(), route)
}

set_route()                         # Left(Oral), the first choice
set_route("inf")                    # Left(Infusion)
set_route(data.frame(amt = 1:2))    # Right(<data frame>)
set_route("iv")                     # Error: 'route' should be one of "oral", "bolus", "infusion"

The C wrapper decodes with match_arg_either_or: character or factor input becomes Left(T) (matched against MatchArg::CHOICES), everything else is converted to R with its TryFromSexp impl and becomes Right. The split follows the R prelude exactly, so a data frame is never tried as a choice and a misspelled choice never falls through to R. An explicit NULL is not a choice either: it goes to R (and fails for DataFrame). The auto-generated @param line names the other kind: One of "oral", "bolus", "infusion", or a data frame. A value R refuses is refused in the same words:

set_route(1:3)   # Error: 'route' must be one of "oral", "bolus", "infusion", or a data frame: got integer

On Either<T, R>, NULL goes to R (a DataFrame arm refuses it). To accept NULL as well, declare Option<Either<T, R>> (a NULL formal) or Missing<Option<Either<T, R>>> (the choice vector stays the formal; omitted is Absent, NULL is Present(None)).

The layers of the previous sections compose with it, outermost first: Option<Either<T, R>> has a NULL formal and turns NULL into None, Missing<Either<T, R>> and Missing<Option<Either<T, R>>> keep the choice vector and report an omitted argument as Absent. choices("a", "b") works the same way on Either<String, R>. Impl methods take all of these through match_arg(p) / choices(p = "..."), trait methods the choices forms (choices(p = "...") on Either<String, R> or Missing<Either<String, R>>). The parameter types of a #[miniextendr] trait also cross its trait ABI, which converts each one with TryFromSexp / IntoR; Option<Either<..>> has a TryFromSexp impl (NULL → None, otherwise the left-first Either conversion, not the choice decoding) but no IntoR, so the Option layer is not available on a trait method. The other arm’s name in the @param line comes from its Rust type (DataFrame is “a data frame”, List “a list”, f64 “a number”, Vec<String> “a character vector”; a type R has no name for is shown in code format). A layer inside the left arm (Either<Option<T>, R>) is a compile error: the choice type has to be the left arm.

The other arm has to read something other than character or factor input, since every such argument goes to the choice. An arm that reads only strings or factors could receive at most NULL, so it is a compile error that names the parameter and the arm: String, &str, Vec<String>, PathBuf, a type alias or #[derive(TryFromSexp)] newtype of one, your own MatchArg or RFactor enum, and any type whose TryFromSexp::CHARACTER_ONLY is true (see Extending miniextendr). The check reads the arm’s conversion, not its name. An Option<String> arm is refused too, since only NULL could reach it: for a NULL alternative, declare the parameter as Option<Either<T, R>>. AsCharacter and AsCharacterVec are allowed, because they also read numbers the way as.character() does (101L reaches the arm as "101").

🔗Several Choices or Another Value

several_ok takes the same split with a list on the left: Either<Vec<T>, R> or Either<Box<[T]>, R> accepts one or more choices or a value of another kind (#1612):

#[miniextendr]
pub fn set_routes(
    #[miniextendr(match_arg, several_ok)] routes: Either<Vec<Route>, DataFrame>,
) -> String {
    match routes {
        Either::Left(routes) => format!("{} routes", routes.len()),
        Either::Right(doses) => format!("{} dose rows", doses.nrow()),
    }
}
set_routes <- function(routes = c("oral", "bolus", "infusion")) {
  if (is.character(routes) || is.factor(routes)) routes <- .miniextendr_match_arg_several(routes, c("oral", "bolus", "infusion"), "routes")
  .Call(C_mypkg_set_routes, .call = sys.call(), routes)
}

set_routes()                        # Left([Oral, Bolus, Infusion]), every choice
set_routes(c("inf", "or"))          # Left([Infusion, Oral]), in the order given
set_routes(data.frame(amt = 1:2))   # Right(<data frame>)
set_routes(c("oral", "iv"))         # Error: 'routes' element 2 ("iv") should be one of ...
set_routes(character(0))            # Error: 'routes' must be of length >= 1

Character or factor input is matched element by element, as for a plain several_ok list (see Multiple Choices with several_ok), and an omitted argument selects every choice as Left. Anything else goes to R, and a value R refuses reads 'routes' must be one or more of "oral", "bolus", "infusion", or a data frame: got integer. An explicit NULL goes to R too: that is the one difference from several_ok on Vec<T>, where NULL selects every choice. Only the owned containers decode under Either: Either<[T; N], R> and Either<&[T], R> are compile errors, and so is an R that reads only character input (Either<Vec<T>, Vec<String>>), as for a scalar choice.

The layers stack as for a scalar Either: Missing<Either<Vec<T>, R>> keeps the choice vector and reports an omitted argument as Absent (an explicit NULL is Present of the R conversion), Option<Either<Vec<T>, R>> has a NULL formal and reads both an omitted argument and NULL as None, and Missing<Option<Either<Vec<T>, R>>> keeps the choice vector with Absent for an omitted argument and Present(None) for NULL. A bare Option<Vec<T>> stays a compile error, since NULL already means every choice there. choices("a", "b"), several_ok works the same way on Either<Vec<String>, R>. Impl methods take all of these through match_arg_several_ok(p) / choices_several_ok(p = "..."); trait methods take choices_several_ok(p = "...") on Either<Vec<String>, R> or Missing<Either<Vec<String>, R>>. The auto-generated @param line reads One or more of "oral", "bolus", "infusion", or a data frame.

🔗Refusing an Explicit NULL

A plain choice reads an explicit NULL as its first choice, as match.arg() does, and an Either<T, R> choice hands it to R, which may refuse it in generic words ('route' must be one of "oral", "bolus", "infusion", or a data frame: got NULL). When the first choice is a setting rather than a neutral default, or when NULL deserves its own hint, refuse it with not_inherits:

#[miniextendr]
pub fn set_rule(
    #[miniextendr(
        match_arg,
        not_inherits("NULL", message = "`rule` can't be NULL; use \"auto\" to reset it.")
    )]
    rule: Missing<Either<Rule, DataFrame>>,
) -> String { /* ... */ }
set_rule <- function(rule = c("include", "exclude", "auto")) {
  if (!isTRUE(missing(rule) || !inherits(rule, "NULL"))) .miniextendr_arg_error("rule", message = "`rule` can't be NULL; use \"auto\" to reset it.")
  if (!missing(rule) && !identical(rule, c("include", "exclude", "auto")) && (is.character(rule) || is.factor(rule))) rule <- .miniextendr_match_arg(rule, c("include", "exclude", "auto"), "rule")
  .Call(C_mypkg_set_rule, .call = sys.call(), if (missing(rule) || identical(rule, c("include", "exclude", "auto"))) quote(expr=) else rule)
}

NULL’s implicit class is "NULL", so inherits(NULL, "NULL") is TRUE. The class checks run before the choice is matched, so NULL is refused before it can become the first choice or reach R. The refusal raises the same condition as a failed choice (rust_error, kind = "conversion", e$param), and an omitted Missing<..> argument still passes as Absent. It works on every form in the table above, several_ok included, and on impl and trait methods through not_inherits(p(class = "NULL", message = "...")). On an Option<..> parameter the check lets NULL through, since NULL is that parameter’s None; drop the Option instead. See Refusing classes for the attribute itself.

🔗Rename Variants

Same syntax as RFactor but with #[match_arg(...)]:

#[derive(Copy, Clone, MatchArg)]
#[match_arg(rename_all = "snake_case")]
pub enum BuildStatus {
    InProgress,    // choice: "in_progress"
    Completed,     // choice: "completed"
}

#[derive(Copy, Clone, MatchArg)]
pub enum Priority {
    #[match_arg(rename = "lo")]  Low,
    #[match_arg(rename = "med")] Medium,
    #[match_arg(rename = "hi")]  High,
}

🔗Aliases

#[match_arg(alias = "...")] on a variant accepts another spelling of its choice without making it a choice of its own. It is repeatable, and rename_all does not change it:

#[derive(Copy, Clone, MatchArg)]
#[match_arg(rename_all = "lower")]
pub enum Color {
    Red,
    #[match_arg(alias = "grey")]
    #[match_arg(alias = "gris", listed = false)]
    Gray,
    Blue,
}

#[miniextendr]
pub fn paint(#[miniextendr(match_arg)] color: Color) -> String { /* ... */ }
paint(color = c("red", "gray", "blue"))   # the formal and the usage
paint("grey")   # Gray
paint("gris")   # Gray
paint("gr")     # Gray: a unique prefix of "gray", as before the alias
paint("gre")    # Error: 'color' should be one of "red", "gray", "blue" (or "grey" for "gray")
  • An alias matches only when typed in full. Prefixes match the choices alone, so an alias never makes a prefix ambiguous, and a prefix of an alias is no match.
  • Matching tries a choice typed exactly, then an alias typed exactly, then a unique prefix of a choice.
  • Every matcher reads the aliases: the wrapper’s check, the Option, Missing and Either forms, several_ok element by element (c("grey", "red") is Gray, Red), the derived TryFromSexp, and match_arg_param() / match_arg_param_with_default().
  • Every error that lists the choices names the aliases after them: (or "grey" for "gray"), several joined as (or "oral" for "extravascular", "grey" for "gray"), in the order of the variants, then as written on a variant. That is the “should be one of” error of the wrapper’s check and of several_ok, the error of the derived TryFromSexp and of match_arg_param() / match_arg_param_with_default(), and the Either error ('color' must be one of "red", "gray", "blue" (or "grey" for "gray"), or a number: got logical). A default = "..." moves its choice to the front of the list, not its aliases.
  • listed = false keeps the aliases of its own #[match_arg(...)] out of every error; they are matched as before. A type whose aliases are all unlisted gets the errors of a type without aliases. listed without an alias in the same attribute, given twice, or with a value other than true / false is a compile error.
  • The formal, the usage and the @param line list the choices only.
  • The wrapper passes the aliases to its check as aliases = base::c("grey" = "gray"), and the clause that names the listed ones as listed = " (or \"grey\" for \"gray\")", rendered in Rust by the code that words the Rust-side errors, so R and Rust word it alike. A type without aliases gets the same wrapper as before aliases existed.
  • The derive refuses, at the alias, one that is empty, "NA", already a choice, given twice, or a prefix of another variant’s choice (alias = "re" on Gray would change what "re", today "red", selects). A prefix of its own choice is allowed.

choices("a", "b") parameters take no aliases yet (#1873).

🔗Via #[miniextendr]

#[miniextendr(match_arg)]
#[derive(Copy, Clone)]
pub enum Mode { Fast, Safe, Debug }

🔗For an Enum You Do Not Own (Newtype)

#[derive(MatchArg)] has to sit on the enum’s declaration, so it cannot be attached to an enum from a crate that does not depend on miniextendr (the usual shape when an R package wraps an existing Rust library). Wrap the foreign enum in a newtype and implement the three traits the derive would have emitted. MatchArg (miniextendr_api::match_arg::MatchArg) is public, and so are the helpers the derive leans on:

use miniextendr_api::match_arg::MatchArg;
use miniextendr_api::{IntoR, SEXP, SexpError, TryFromSexp};

// Owned by the wrapped library; no miniextendr types anywhere near it.
use wrapped::Interp;

#[derive(Copy, Clone)]
pub struct InterpChoice(pub Interp);

impl MatchArg for InterpChoice {
    const CHOICES: &'static [&'static str] = &["linear", "cubic", "nearest"];

    fn from_choice(choice: &str) -> Option<Self> {
        Some(InterpChoice(match choice {
            "linear" => Interp::Linear,
            "cubic" => Interp::Cubic,
            "nearest" => Interp::Nearest,
            _ => return None,
        }))
    }

    fn to_choice(self) -> &'static str {
        match self.0 {
            Interp::Linear => "linear",
            Interp::Cubic => "cubic",
            Interp::Nearest => "nearest",
        }
    }
}

impl TryFromSexp for InterpChoice {
    type Error = SexpError;
    const CHARACTER_ONLY: bool = true;
    fn try_from_sexp(sexp: SEXP) -> Result<Self, SexpError> {
        miniextendr_api::match_arg_from_sexp(sexp).map_err(Into::into)
    }
}

impl IntoR for InterpChoice {
    type Error = std::convert::Infallible;
    fn try_into_sexp(self) -> Result<SEXP, Self::Error> { Ok(self.into_sexp()) }
    unsafe fn try_into_sexp_unchecked(self) -> Result<SEXP, Self::Error> { self.try_into_sexp() }
    fn into_sexp(self) -> SEXP { self.to_choice().into_sexp() }
}

#[miniextendr]
pub fn interpolate(#[miniextendr(match_arg)] method: InterpChoice) -> f64 {
    wrapped::run(method.0)
}

Everything downstream of the trait is shared with derived enums: the match.arg() prelude, the choices spliced into the formal default (function(method = c("linear", "cubic", "nearest"))), partial matching, factor input, several_ok (Vec<InterpChoice>), the Vec<T> return path (via the blanket IntoRVecElement bridge), impl-block match_arg(param) attributes, and the auto-injected @param choices text. to_choice is an exhaustive match, so adding a variant to the wrapped enum without updating the newtype is a compile error rather than silent drift. CHARACTER_ONLY = true says the conversion reads only character or factor input, as a derived enum’s does, so Either<InterpChoice, String> is refused like Either<Route, String> (see Choice or Another Value).

Aliases go in MatchArg::ALIASES, not in from_choice: the wrapper’s check reads ALIASES and never reaches a from_choice that maps "nn", so the two would disagree. MatchArgAlias::new makes a listed alias, MatchArgAlias::unlisted one the errors don’t name:

const ALIASES: &'static [MatchArgAlias] = &[
    MatchArgAlias::new("nn", "nearest"),
    MatchArgAlias::unlisted("straight", "linear"),
];

The wrappers writer refuses the aliases the derive refuses, plus one whose choice is not in CHOICES, with an error naming the parameter’s placeholder. rpkg/src/rust/match_arg_foreign_tests.rs is the reference fixture.

🔗Inline String Choices

For simple cases where you don’t need an enum, use choices(...) on a &str parameter:

#[miniextendr]
pub fn correlate(
    x: f64, y: f64,
    #[miniextendr(choices("pearson", "kendall", "spearman"))] method: &str,
) -> String {
    format!("method={}, cor={}", method, x * y)
}

🔗Multiple Choices with several_ok

R’s match.arg(..., several.ok = TRUE) accepts multiple values from the choice list and returns a character vector. miniextendr exposes this with several_ok, which is valid on both match_arg and choices:

// Enum: Vec<Mode> - each element validated against MatchArg::CHOICES
#[miniextendr]
pub fn pick_modes(#[miniextendr(match_arg, several_ok)] modes: Vec<Mode>) -> String { ... }

// Inline: Vec<String> - each element validated against the inline list
#[miniextendr]
pub fn pick_metrics(
    #[miniextendr(choices("mean", "median", "sd", "var"), several_ok)] metrics: Vec<String>,
) -> String { ... }

Accepted container shapes: Vec<T>, Box<[T]>, &[T], and [T; N]; under Missing<..> or Either<.., R>, Vec<T> and Box<[T]> only. several_ok without match_arg or choices is a compile error (no choice list to validate against). several_ok on a scalar type (e.g. Mode without a Vec) is also a compile error, and so is Option<Vec<T>>. Either<Vec<T>, R> takes one or more choices or a value of another kind (see Several Choices or Another Value).

An omitted argument, and an explicit NULL, select the full choice list (under Either, NULL goes to the R arm instead). Pass a single string to get partial matching, or a character vector to select several choices; each element is matched exactly or as a unique prefix. Missing<Vec<T>> (or Missing<Box<[T]>>) keeps the choice vector as the formal and reports an omitted argument as Missing::Absent instead; NULL still selects every choice (see Omitted Choice).

Validation is stricter than base::match.arg(several.ok = TRUE), which keeps the elements that match and silently drops the rest as long as one element matched (so f(c("alpha", "zzz")) would behave like f("alpha")). The generated prelude routes several_ok parameters through an internal helper that requires every element to match and reports the first that does not, with its position (#1472):

pick_modes <- function(modes = c("Fast", "Safe", "Debug")) {
  modes <- .miniextendr_match_arg_several(modes, c("Fast", "Safe", "Debug"), "modes")
  .Call(C_mypkg_pick_modes, modes)
}

pick_modes(c("Fast", "zzz"))
#> Error in pick_modes(c("Fast", "zzz")) :
#>   'modes' element 2 ("zzz") should be one of "Fast", "Safe", "Debug"

The Rust side (match_arg_vec_from_sexp) still checks every element against MatchArg::CHOICES, so a value that bypasses the wrapper is caught too.

🔗On Impl-Block Methods

Rust rejects attribute macros on function parameters inside impl items, so match_arg / choices / several_ok on impl methods use method-level attributes that name the parameter. This works for all class systems (r6, env, s3, s4, s7, vctrs) on both constructors and instance methods:

#[miniextendr(r6)]
impl Counter {
    // Constructor: first choice becomes the R default.
    #[miniextendr(match_arg(mode))]
    pub fn new(mode: Mode) -> Self { ... }

    // several_ok variant - note the distinct attribute name.
    #[miniextendr(match_arg_several_ok(modes))]
    pub fn reset(&mut self, modes: Vec<Mode>) -> i32 { ... }

    // Inline string choices - pass the list as a comma-separated string.
    #[miniextendr(choices(level = "low, medium, high"))]
    pub fn describe(level: String) -> String { ... }
}

Each form generates the same R match.arg prelude you get on standalone functions, including the choices vector as the formal default.

The vctrs class system accepts match_arg on its fn new() constructor even though vctrs constructors return a data vector (Vec<T>) rather than Self. The vctrs generator recognizes a receiverless new as the constructor regardless of return type.

🔗Auto-Injected @param Docs

When you leave a match_arg parameter undocumented, miniextendr fills in the roxygen @param line at write time using the enum’s CHOICES; a choices(...) parameter gets the same line from its literal list:

#' @param mode One of "Fast", "Safe", "Debug".

The line also names the other accepted values and what omitting the argument means (see the sections above). This runs for standalone functions and for impl-block and trait methods across every class system, with one exception: R6 trait methods. They live in Type$Trait$method, not among the R6 generator’s public methods, so roxygen2 has no method section to list their arguments in; a plain @param there lands in a top-level \arguments of the class page, detached from any usage. Env classes have no usage section for their methods, inherent or trait, and neither do the Type$Trait$method statics of S3 and vctrs classes or the attr(Type, "Trait")$method statics of S7 classes, so there the text goes into a \describe list under the method’s description instead of an @param line. Explicit @param lines you write yourself are preserved verbatim; only missing entries are auto-generated. A block with @describeIn, @inheritParams, or an @rdname naming another page gets no generated line: the page it joins or the topic it inherits from documents the parameter. On a file-stem page several functions opt into (@rdname <file stem>) the line is kept only when no function there documents the parameter (see Parameters on shared pages).

🔗One entry per parameter name on a shared page

Functions documented on one Rd page (a shared @rdname, such as the functions of one source file that opt into its file-stem page, or the methods of one class) get one \item per parameter name. roxygen2 merges the blocks of a page in file order, and when two blocks document the same name the later block’s line replaces the earlier one. On a class page that includes a method’s generated line replacing an explicit @param written on an earlier method’s block. On a file-stem page the generated line is left out when another function there documents the parameter, but when none does, every function’s generated line is written. Two functions on one page that take a mode of different types (Mode and Missing<Option<Mode>>) therefore show only the last one’s text.

miniextendr does not merge the texts into one line. Give such parameters distinct names, or leave such functions on pages of their own (the default for a free function without @rdname).


🔗Returning Vec<Enum>

Functions can return Vec<T> for any MatchArg enum. Each variant round-trips to its choice string:

#[miniextendr]
pub fn all_modes() -> Vec<Mode> {
    vec![Mode::Fast, Mode::Safe, Mode::Debug]
}
all_modes()
# [1] "Fast"  "Safe"  "Debug"

This is provided by a blanket impl<T: MatchArg> IntoR for Vec<T> in miniextendr-api. No extra derive is required.


🔗MatchArg as Base Trait

MatchArg is the base trait for all enum-like types. RFactor requires MatchArg as a supertrait, so any RFactor type also has MatchArg::CHOICES, from_choice(), and to_choice(). It can also be implemented by hand on a newtype (see For an Enum You Do Not Own). Use MatchArg as a bound for generic code over both systems:

use miniextendr_api::MatchArg;

fn describe_choices<T: MatchArg>() -> String {
    T::CHOICES.join(", ")
}

fn lookup<T: MatchArg>(choice: &str) -> Option<T> {
    T::from_choice(choice)
}

🔗Comparison Table

FeatureRFactorMatchArg
R storagefactor(1, levels=c(...))"Fast" (character)
ValidationType check (is factor with correct levels)match.arg() with partial matching
Default on NULLErrorFirst choice (Option<T>: None; several_ok: all choices; under Either: the R arm)
Omitted argumentErrorFirst choice (Option<T>: None; Missing<..>: Absent)
Vec supportFactorVec<T>, FactorOptionVec<T>Vec<T> return + several_ok inputs
Partial matchingNoYes ("F" → "Fast")
Factor inputNativeConverted to character first
Use caseCategorical dataParameter selection

🔗When to Use Which

RFactor when:

  • Data is categorical (colors, species, status codes)
  • Working with R functions expecting factors (table(), lm(), ggplot2)
  • Need vector support with NA handling
  • Factor level ordering matters

MatchArg when:

  • Building an API with string-based options
  • Want R’s match.arg() partial matching and error messages
  • Want a default value when the argument is omitted
  • Validating user input parameters

🔗See Also