Call Attribution and `match.call()`
Generated R wrappers pass .call = match.call() into every .Call() so that errors raised from Rust are attributed to the user's call frame, with formal parameters matched by name. This page shows the difference using a real, runnable fixture.
Generated R wrappers pass .call = match.call() into every .Call() so that errors raised from Rust are attributed to the userβs call frame, with formal parameters matched by name. This page shows the difference using a real, runnable fixture.
πThe fixture
rpkg/src/rust/call_attribution_demo.rs defines two functions that raise the same error message. Only the wrapper differs.
// Wrapped path. Generated R wrapper passes `.call = match.call()` into the
// C entry; on panic, `Rf_errorcall(call, msg)` shows the user's call frame.
#[miniextendr]
pub fn call_attr_with(_left: i32, _right: i32) -> i32 {
panic!("left + right is too risky")
}
// Unwrapped path. `extern "C-unwind"` bypasses the wrapper entirely β there is
// no call slot and no `with_r_unwind_protect`. We raise an R error directly
// with `Rf_error`, which carries no call attribution.
#[miniextendr]
#[unsafe(no_mangle)]
#[allow(non_snake_case)]
pub extern "C-unwind" fn C_call_attr_without(_left: SEXP, _right: SEXP) -> SEXP {
unsafe {
::miniextendr_api::ffi::Rf_error(
c"%s".as_ptr(),
c"left + right is too risky".as_ptr(),
)
}
}
Generated R wrappers (excerpted from rpkg/R/miniextendr-wrappers.R):
call_attr_with <- function(left, right) {
# ... preconditions ...
.val <- .Call(C_call_attr_with, .call = match.call(), left, right)
# ... error_in_r demux ...
}
unsafe_C_call_attr_without <- function(left, right) {
.val <- .Call(C_call_attr_without, left, right) # no .call slot
# ...
}
The extern "C-unwind" path is registered directly as the .Call symbol, so there is nowhere to thread a call SEXP.
πThe transcript
These two functions are internal demo fixtures (not exported); the ::: prefix is intentional.
> library(miniextendr)
> miniextendr:::call_attr_with(1L, 2L)
Error in miniextendr:::call_attr_with(left = 1L, right = 2L) :
left + right is too risky
> miniextendr:::unsafe_C_call_attr_without(1L, 2L)
Error in miniextendr:::unsafe_C_call_attr_without(1L, 2L) :
left + right is too risky
Wrapped inside another function so the difference is sharper:
> outer_with <- function(x) miniextendr:::call_attr_with(x, x + 1L)
> outer_with(5L)
Error in miniextendr:::call_attr_with(left = x, right = x + 1L) :
left + right is too risky
> outer_without <- function(x) miniextendr:::unsafe_C_call_attr_without(x, x + 1L)
> outer_without(5L)
Error in miniextendr:::unsafe_C_call_attr_without(x, x + 1L) :
left + right is too risky
Programmatic comparison via tryCatch:
> e_with <- tryCatch(outer_with(5L), error = identity)
> e_without <- tryCatch(outer_without(5L), error = identity)
> class(e_with)
[1] "rust_error" "simpleError" "error" "condition"
> class(e_without)
[1] "simpleError" "error" "condition"
> conditionCall(e_with)
miniextendr:::call_attr_with(left = x, right = x + 1L)
> conditionCall(e_without)
miniextendr:::unsafe_C_call_attr_without(x, x + 1L)πWhat .call = match.call() buys you
- Formal parameter names matched.
call_attr_with(left = 1L, right = 2L)reads better thancall_attr_with(1L, 2L)and is robust to positional vs. named call style. - Public function name, not internal symbol. Errors blame
call_attr_with, notminiextendr:::unsafe_C_call_attr_without. Triple-colon paths in error messages are a leak of internals. - Structured
rust_errorclass. The wrapped path goes through theerror_in_rdecoder and returns a condition with classrust_error, which downstream handlers can catch specifically. The unwrapped path produces a plainsimpleError. - Stable across nesting. Whether the user calls the function directly or from another function,
match.call()always captures the immediate callerβs expression, with the call written as the user wrote it.
πHow it flows end to end
R user calls: call_attr_with(1L, 2L)
β
βΌ
R wrapper: .Call(C_call_attr_with, .call = match.call(), left, right)
β
βΌ
C wrapper: extern "C-unwind" fn(__miniextendr_call: SEXP, left: SEXP, right: SEXP)
β
βββ with_r_unwind_protect(closure, Some(__miniextendr_call))
β
βΌ
Rust panic caught
β
βΌ
Rf_errorcall(__miniextendr_call, "left + right is too risky")
β
βΌ
R error: "Error in call_attr_with(left = 1L, right = 2L) : ..."
For the unwrapped extern "C-unwind" path, the call slot does not exist, so the panic-to-error path becomes Rf_error(msg) and R falls back to sys.call() of the wrapper frame.
πWhere this is emitted
Every .Call() inside generated R wrappers goes through one source of truth: DotCallBuilder in miniextendr-macros/src/r_wrapper_builder.rs, which always prepends .call = match.call(). The C wrapper builder in miniextendr-macros/src/c_wrapper_builder.rs always declares __miniextendr_call: SEXP as the first parameter, so the convention is symmetric.
It applies uniformly to:
- Standalone
#[miniextendr]functions - All six class systems (R6, S3, S4, S7, Env, Vctrs) β constructors, instance methods, static methods, active bindings, finalizers,
deep_clone - All trait implementations across all class systems
match_argchoices helper calls- Sidecar
Type_get_field/Type_set_fieldaccessors generated by#[derive(ExternalPtr)]
πWhere it is intentionally absent
extern "C-unwind"functions registered directly with#[miniextendr]. The function is the C entry point β there is no generated wrapper and no call slot. This is the demo above. Use only for low-level fixtures and tests where you control the error path manually.vctrs_deriveboilerplate βformat.<class>,vec_ptype2.<class>.<class>, etc. β pure R, no.Call().
πWhere .call = NULL is used instead of match.call()
Five lambda dispatch sites cannot use match.call() because the lambda is invoked by R6/S7 dispatch machinery, not by user code. match.call() inside those lambdas would capture the dispatch frame (e.g., R6$finalize(), S7::prop_get()), not the userβs obj$field access. The generated .Call() instead passes .call = NULL. The %||% sys.call() fallback in error_in_r_check_lines then surfaces the nearest meaningful frame.
The five sites are:
- R6 finalizer β
finalize = function() .Call(C_Type__finalize, .call = NULL, private$.ptr) - R6
deep_cloneβdeep_clone = function(name, value) .Call(C_Type__deep_clone, .call = NULL, private$.ptr, name, value) - S7 property validator β
validator = function(value) .Call(C_Type__validate_prop, .call = NULL, value) - S7 property getter β
getter = function(self) .Call(C_Type__get_prop, .call = NULL, self@.ptr) - S7 property setter β
setter = function(self, value) { .Call(C_Type__set_prop, .call = NULL, self@.ptr, value); self }
This is implemented via DotCallBuilder::null_call_attribution() in miniextendr-macros/src/r_wrapper_builder.rs. The C wrapper still receives __miniextendr_call: SEXP (it always does) and gets R_NilValue; make_rust_condition_value stores it and the R-side %||% sys.call() recovers the userβs frame.
πReproducing the transcript
just configure
just rcmdinstall
Rscript -e '
library(miniextendr)
try(miniextendr:::call_attr_with(1L, 2L))
try(miniextendr:::unsafe_C_call_attr_without(1L, 2L))
'
The fixture lives at rpkg/src/rust/call_attribution_demo.rs.