Project-wide defaults for #[miniextendr] options, controlled via Cargo features.

πŸ”—Problem

Options like strict and coerce must normally be specified on every #[miniextendr] annotation:

#[miniextendr(strict, coerce)]
fn add(a: i64, b: i64) -> i64 { a + b }

#[miniextendr(strict, coerce)]
fn mul(a: i64, b: i64) -> i64 { a * b }

This is repetitive for packages that want a consistent policy across all exported functions.

πŸ”—Solution

Enable a Cargo feature to apply the option everywhere. Individual functions can still opt out with no_ prefixed keywords.

# Cargo.toml
[dependencies]
miniextendr-api = { version = "0.1", features = ["strict-default"] }
// All functions now use strict conversions automatically
#[miniextendr]
fn add(a: i64, b: i64) -> i64 { a + b }

// Opt out for this one function
#[miniextendr(no_strict)]
fn legacy_add(a: i64, b: i64) -> i64 { a + b }

πŸ”—Available Features

FeatureEffectScopeOpt-out keyword
strict-defaultStrict checked conversions for lossy types (i64, u64, isize, usize)fns + impl blocksno_strict
coerce-defaultAuto-coerce parameters (e.g., f32 from f64)fns + methodsno_coerce
fast-defaultFast-path knobs: drop R-side stopifnot() and emit .call = NULLfns + impl blocksno_fast
r6-defaultR6 class system for impl blocks (instead of env)impl blocksenv, s7, etc.
s7-defaultS7 class system for impl blocks (instead of env)impl blocksenv, r6, etc.
worker-defaultForce worker thread execution (implies worker-thread)fns + methodsno_worker

All six *-default features above are denylisted from rpkg’s auto-detected default build (rpkg/tools/detect-features.R) β€” enabling any of them flips codegen semantics crate-wide, so no PR-gating job ever builds or runs the R wrappers they generate. Their only runtime coverage is the scheduled feature-legs job in .github/workflows/ci.yml (weekly + workflow_dispatch), which rebuilds rpkg with one feature bundle on top of the detected base set and re-runs tests/testthat/test-feature-defaults.R against it.

πŸ”—Hardcoded Defaults (No Longer Feature-Controlled)

The following were previously opt-in features but are now always enabled by default:

DefaultEffectNotes
Tagged-condition transportTransport Rust errors as R conditions (panics, Err, None β†’ tagged SEXP β†’ R wrapper raises)Only path; no opt-out. unwrap_in_r is orthogonal (Result-as-value vs Result-as-error-boundary).
Main threadAll code runs on R’s main threadOpt into the worker thread with worker.

πŸ”—Orthogonality

fast-default is orthogonal to strict-default and coerce-default β€” any combination is valid:

FeaturesEffect
fast-default onlyFast wrappers with permissive conversions
fast-default + strict-defaultFast wrappers with strict i64/u64/… checking
fast-default + coerce-defaultFast wrappers with auto-coercion
All threeFast + strict + coerce

πŸ”—Mutual Exclusivity

These feature pairs cannot be enabled simultaneously (compile error):

  • r6-default + s7-default

πŸ”—Feature Forwarding

Features are defined in miniextendr-macros and forwarded by miniextendr-api:

miniextendr-api/strict-default  β†’  miniextendr-macros/strict-default
miniextendr-api/fast-default    β†’  miniextendr-macros/fast-default

Users should enable features on miniextendr-api (or their package’s Cargo.toml features section). The forwarding is automatic.

πŸ”—Detailed Behavior

πŸ”—Standalone Functions

Feature defaults apply to #[miniextendr] on standalone functions:

// With strict-default + coerce-default features enabled:

#[miniextendr]                    // strict=true, coerce=true (from features)
fn f1(x: i64) -> i64 { x }

#[miniextendr(no_strict)]         // strict=false, coerce=true
fn f2(x: i64) -> i64 { x }

#[miniextendr(no_coerce)]         // strict=true, coerce=false
fn f3(x: f32) -> f32 { x }

#[miniextendr(no_strict, no_coerce)]  // strict=false, coerce=false
fn f4(x: i64) -> i64 { x }

πŸ”—Impl Blocks

strict-default applies to the impl block level. r6-default/s7-default set the class system default:

// With r6-default + strict-default features enabled:

#[miniextendr]                    // class_system=R6, strict=true
impl MyType { ... }

#[miniextendr(env)]               // class_system=Env (overridden), strict=true
impl MyType { ... }

#[miniextendr(no_strict)]         // class_system=R6, strict=false
impl MyType { ... }

#[miniextendr(s7)]                // class_system=S7 (overridden), strict=true
impl MyType { ... }

πŸ”—Methods

Per-method options (worker, main_thread, coerce) also respect feature defaults:

// With worker-default + coerce-default features enabled:

#[miniextendr(r6)]
impl MyType {
    #[miniextendr(r6())]              // worker=true, coerce=true (from features)
    fn method1(&self, x: f32) { }

    #[miniextendr(r6(no_worker))]     // worker=false, coerce=true
    fn method2(&self) { }

    #[miniextendr(r6(no_coerce))]     // worker=true, coerce=false
    fn method3(&self, x: f32) { }
}

πŸ”—fast-default

The fast-default feature bundles two performance knobs that are applied by default to every #[miniextendr] function and impl block:

  • no_preconditions: drops the R-side stopifnot(...) block. The stopifnot check costs ~300 ns/call for a typical i32 argument. When omitted, type errors still propagate from Rust’s TryFromSexp, but the message comes from the Rust side (β€œfailed to convert parameter β€˜x’ to i32”) rather than R’s β€œmust be numeric, logical, or raw”.

  • no_call_attribution: emits .call = NULL instead of .call = match.call() in the .Call(...) invocation. This saves ~1200 ns per call by skipping R’s match.call() evaluation. On the error path, R’s stop() fills in sys.call() for the calling frame (because call. defaults to TRUE), so conditionCall(e) remains non-NULL β€” the error UX difference is subtle: match.call() captures named argument positions, while sys.call() does not.

Combined, fast (= no_preconditions + no_call_attribution) delivers a 7.78Γ— speedup on the single-call fast path and 8.54Γ— for three-argument functions (see analysis/scaffolding-deep-findings-2026-05-20.md).

// With fast-default feature enabled:

#[miniextendr]                   // no_preconditions=true, no_call_attribution=true
fn fast_fn(x: i32) -> i32 { x }

// Opt out for a function where R-side type errors need the full UX:
#[miniextendr(no_fast)]          // no_preconditions=false, no_call_attribution=false
fn user_facing_fn(x: i32) -> i32 { x }

// Or restore just one knob:
#[miniextendr(no_call_attribution = false)]  // preconditions dropped, match.call() restored
fn semi_fast(x: i32) -> i32 { x }

The same applies to impl blocks:

// With fast-default + r6-default:

#[miniextendr]                   // R6, fast (all methods inherit)
impl MyCounter { ... }

// One impl block where the full UX matters:
#[miniextendr(no_fast)]
impl UserFacingType { ... }

πŸ”—unwrap_in_r

unwrap_in_r is orthogonal to the tagged-condition transport. It controls whether Result<T, E> is treated as a Rust-origin failure (Err β†’ tagged condition β†’ stop()) or as a value to surface to R as a list with an $error slot. There is no conflict to resolve:

#[miniextendr(unwrap_in_r)]
fn fallible() -> Result<i32, String> { Ok(42) }

πŸ”—Resolution Order

For each option, the resolution is:

  1. Explicit attribute – strict or no_strict on the item β†’ uses that value
  2. Feature default – cfg!(feature = "strict-default") β†’ uses the feature setting (for feature-controlled options)
  3. Built-in default – main_thread=true, tagged-condition transport always on, false for other boolean options, Env for class system

Explicit attributes always win over feature/built-in defaults.

πŸ”—Example: Strict-by-Default Package

# Cargo.toml
[features]
default = ["strict-default"]
strict-default = ["miniextendr-api/strict-default"]

[dependencies]
miniextendr-api = { version = "0.1" }
// All functions use strict conversions
#[miniextendr]
fn process(x: i64) -> i64 { x * 2 }

// This specific function needs lossy behavior for backwards compat
#[miniextendr(no_strict)]
fn legacy_process(x: i64) -> i64 { x * 2 }

πŸ”—Example: R6-by-Default Package

# Cargo.toml
[features]
default = ["r6-default"]
r6-default = ["miniextendr-api/r6-default"]
// All impl blocks generate R6 classes
#[miniextendr]
impl Counter { ... }    // R6

// This one needs env for specific reasons
#[miniextendr(env)]
impl LightWrapper { ... }  // env (overridden)

πŸ”—Complete Opt-Out Keywords Reference

KeywordWhereCancels
no_strict#[miniextendr(no_strict)] on fn, #[miniextendr(no_strict)] on implstrict-default feature
no_coerce#[miniextendr(no_coerce)] on fn, #[miniextendr(r6(no_coerce))] on methodcoerce-default feature
no_fast#[miniextendr(no_fast)] on fn or implfast-default feature (restores both stopifnot + match.call())
no_preconditions#[miniextendr(no_preconditions)] on fn or implDrops stopifnot block (can be used independently of no_call_attribution)
no_call_attribution#[miniextendr(no_call_attribution)] on fn or implEmits .call = NULL (can be used independently of no_preconditions)
fast#[miniextendr(fast)] on fn or implBundle alias for both no_preconditions + no_call_attribution; also opts back in when used with no_fast
worker#[miniextendr(worker)] on fn, #[miniextendr(r6(worker))] on methodBuilt-in main thread default
no_worker#[miniextendr(no_worker)] on fn, #[miniextendr(r6(no_worker))] on methodworker-default feature
env / r6 / s7 / s3 / s4#[miniextendr(env)] on implr6-default or s7-default feature